Benchmarked using Soroban testutils Env (unlimited budget). Results are approximate averages from local runs (Soroban SDK test env).
| Function | Scenario | Avg CU | Storage Impact |
|---|---|---|---|
create_attestation |
Baseline (no metadata/tags) | ~75,000 | ~1.2 KB (attestation + 4 indices) |
revoke_attestation |
Baseline | ~12,000 | ~200 bytes (status + audit append) |
has_valid_claim |
1 attestation (valid) | ~8,500 | read-only |
| 10 attestations (1 valid + 9 noise) | ~25,000 | read-only | |
| 100 attestations (1 valid + 99 noise) | ~180,000 | read-only (SubjectClaimIndex opt saves ~10x vs no index) | |
| Invalid claim (100 attestations) | ~150,000 | read-only | |
has_all_claims |
1 claim | ~9,000 | read-only |
| 5 claims | ~25,000 | read-only | |
| 10 claims | ~40,000 | read-only | |
has_any_claim |
early-exit (first claim matches) | ~9,000 | read-only |
| worst-case (10 claims, no match) | ~70,000 | read-only | |
get_subject_attestations |
page_size=10 (100 total) | ~15,000 | read-only |
| page_size=50 | ~35,000 | read-only | |
| page_size=100 (full) | ~65,000 | read-only |
- Each attestation: ~800 bytes XDR
- Vec index entry per ID: ~32 bytes
- create_attestation: 5 writes (~1.2 KB total)
- revoke: 2 writes (~200 bytes)
- Index Vec grows linearly; prune expired for long-term savings.
has_valid_claim: Scales with claim-specific attestations (SubjectClaimIndex opt). Without opt, 100 attests would be ~1.5M CU.get_subject_attestations: Linear in total attestations, pagination caps CU.- Stellar limits: 75M base CU + CPU/reads.
Why this matters: Every byte stored on the Stellar ledger consumes a base reserve. Issuers pay this cost at upload time and must maintain the minimum balance to keep entries alive. Understanding the per-attestation cost lets issuers budget accurately before deploying at scale.
| Parameter | Value |
|---|---|
| Base reserve per ledger entry | 0.5 XLM |
| Additional reserve per 32 bytes of entry data | 0.5 XLM |
| Minimum account balance (2 entries) | 1 XLM |
Source: Stellar Developer Docs — Lumens The base reserve is a network-level parameter and can be changed by validator vote. Always verify the current value with
stellar network infobefore budgeting.
A baseline create_attestation call (no metadata, no tags) writes 5 ledger entries:
| Entry | Contents | Approx. size |
|---|---|---|
Attestation(id) |
Full attestation struct (id, issuer, subject, claim_type, timestamp, expiration, revoked, imported, bridged, source_chain, source_tx) | ~800 bytes |
SubjectAttestations(subject) |
Vec of attestation IDs for this subject (grows with each new attestation) | ~32 bytes per ID |
IssuerAttestations(issuer) |
Vec of attestation IDs for this issuer | ~32 bytes per ID |
SubjectClaimIndex(subject, claim_type) |
Vec of IDs for fast has_valid_claim lookup |
~32 bytes per ID |
GlobalStats |
Counters (total_attestations, total_revocations, total_issuers) | ~48 bytes |
Total new data written per attestation: ~944 bytes (excluding index Vec overhead already on-chain).
With optional metadata (e.g. a 200-byte JSON string) the attestation entry grows to ~1,000 bytes, and total written data reaches ~1,144 bytes.
Stellar charges reserve based on the number of 32-byte "data chunks" in an entry, rounded up, plus the flat per-entry fee.
reserve_per_entry = base_reserve + ceil(entry_bytes / 32) × data_reserve_per_32_bytes
= 0.5 XLM + ceil(N / 32) × 0.5 XLM
| Entry | Size (bytes) | 32-byte chunks | Reserve (XLM) |
|---|---|---|---|
Attestation(id) — baseline |
800 | 25 | 0.5 + 25 × 0.5 = 13.0 XLM |
Attestation(id) — with 200-byte metadata |
1,000 | 32 | 0.5 + 32 × 0.5 = 16.5 XLM |
| Index Vec entry (32 bytes) | 32 | 1 | 0.5 + 1 × 0.5 = 1.0 XLM |
GlobalStats update |
48 | 2 | shared entry, amortised across all ops |
Note: Index Vec entries are appended to existing ledger entries, not new entries. The marginal reserve cost for each new ID appended to an existing Vec is the data reserve for the additional 32 bytes only (~0.5 XLM), not a full new entry fee.
| Scenario | Approx. XLM reserve |
|---|---|
| Baseline (no metadata) | ~15–16 XLM |
| With 200-byte metadata | ~18–20 XLM |
Revocation (revoke_attestation) |
~1–2 XLM (2 entry updates) |
These are reserve costs, not transaction fees. The XLM is locked, not burned — it is returned if the entry is deleted or the TTL expires and the entry is evicted.
Soroban persistent entries have a TTL (time-to-live) measured in ledgers. When the TTL expires the entry is archived and must be restored (at cost) to be readable again.
| Default TTL | ~30 days (configurable via ttl_days at initialization) |
|---|---|
| Extend TTL | stellar contract extend-ttl or automatic via bump_entry |
| Restore archived entry | stellar contract restore — costs a fee proportional to entry size |
To keep attestations live indefinitely, issuers should run a periodic TTL-extension job. A rough estimate: extending a single 800-byte entry for 30 days costs ~0.001–0.005 XLM in transaction fees (network-dependent).
| Attestations | Approx. total XLM reserve (baseline) |
|---|---|
| 10 | ~150–160 XLM |
| 100 | ~1,500–1,600 XLM |
| 1,000 | ~15,000–16,000 XLM |
| 10,000 | ~150,000–160,000 XLM |
These figures assume each attestation is a new subject+claim_type pair (worst case — all 5 entries are new). If many attestations share the same subject or issuer, the index Vec entries are appended to existing entries and the marginal cost is lower.
- Reuse subjects: Multiple attestations for the same subject share index entries — marginal cost per additional attestation drops from ~15 XLM to ~13.5 XLM.
- Prune expired/revoked entries: Deleting stale attestations releases the locked reserve.
- Avoid large metadata: Each 32 bytes of metadata adds 0.5 XLM to the reserve. Keep metadata under 64 bytes where possible.
- Batch operations:
create_attestations_batchamortises theGlobalStatswrite across multiple attestations in a single transaction. - Monitor TTL: Run a cron job to extend TTLs before entries are archived to avoid restoration fees.
Soroban's persistent storage is a key-value store where each entry is a single
opaque blob. There is no native support for partial reads — every get call
deserialises the entire value regardless of how much of it is actually needed.
This means a Vec<String> index with 10,000 entries is fully loaded into the
WASM heap on every query, even when only 10 items are needed.
Key constraints confirmed from the Soroban SDK and XDR spec:
| Constraint | Detail |
|---|---|
| No partial entry reads | env.storage().persistent().get(key) always returns the full value |
| No server-side filtering | Filtering/slicing must happen in WASM after the full load |
| Max entry size | ~64 KB (XDR limit per ledger entry) |
| Entry granularity | Each distinct key is a separate ledger entry with its own read fee |
| Read fee | Charged per entry accessed, not per byte read |
Conclusion: True lazy loading (reading a slice of a Vec without loading the whole entry) is not possible in Soroban. The only way to achieve partial loading is to split the index across multiple ledger entries — one entry per chunk.
The chunked index splits each Vec<String> index into fixed-size ledger entries
of CHUNK_SIZE = 50 IDs each. A separate count entry tracks the total.
Storage layout:
SubjectAttestationsChunk(addr, 0) → Vec<String> IDs [0..49]
SubjectAttestationsChunk(addr, 1) → Vec<String> IDs [50..99]
SubjectAttestationsChunk(addr, 2) → Vec<String> IDs [100..149]
SubjectAttestationsCount(addr) → u32 total = 150
IssuerAttestationsChunk(addr, 0) → Vec<String>
IssuerAttestationsChunk(addr, 1) → Vec<String>
IssuerAttestationsCount(addr) → u32
Query cost — before vs after:
| Index size | Page size | Chunks loaded (before) | Chunks loaded (after) | Data read reduction |
|---|---|---|---|---|
| 100 IDs | 10 | 1 (full flat Vec) | 1 | ~0% (already 1 entry) |
| 1,000 IDs | 10 | 1 (full flat Vec ~64 KB) | 1 (one chunk ~3.2 KB) | ~95% |
| 10,000 IDs | 10 | 1 (full flat Vec, hits size limit) | 1 (one chunk ~3.2 KB) | ~99% |
| 10,000 IDs | 100 | 1 (full flat Vec) | 2–3 chunks | ~97% |
For a 10-item page against a 10,000-item index, the chunked approach reads
ceil(10/50) + 1 = 2 chunks (~6.4 KB) instead of the entire flat Vec.
Write cost — append:
| Operation | Before | After |
|---|---|---|
add_subject |
1 read + 1 write (full Vec) | 1 read + 1 write (one chunk) |
add_issuer_bulk(N) |
1 read + 1 write (full Vec) | ceil(N/50) reads + writes |
remove_subject/issuer |
1 read + 1 write (full Vec) | 1–2 reads + 1–2 writes (swap-with-last) |
The remove operation uses a swap-with-last strategy: the target ID is replaced with the last ID in the index, then the last slot is popped. This avoids shifting the entire Vec and keeps the remove cost O(chunks_scanned) rather than O(total_ids).
Chunk size rationale:
Each attestation ID is a 64-byte hex string. XDR overhead adds ~8 bytes per entry in a Vec. One chunk of 50 IDs ≈ 50 × 72 = 3,600 bytes — well within the 64 KB entry limit and small enough that even a cross-chunk page boundary only loads 2 chunks.
cargo test chunked_index -- --nocapturecreate_attestations_batch called store_attestation for each subject in the loop.
store_attestation performs three shared-state writes per item:
| Write | Per-item cost |
|---|---|
IssuerAttestations index |
read + write (grows Vec by 1) |
IssuerStats |
read + write |
GlobalStats |
read + write |
For a batch of N subjects this produced 3N reads + 3N writes on those three entries alone.
Batch of 50 — before:
- Issuer index: 50 reads + 50 writes
- Issuer stats: 50 reads + 50 writes
- Global stats: 50 reads + 50 writes
- Total: 150 extra reads + 150 extra writes
The loop now only writes the attestation record and the per-subject index (both are inherently per-item). The three shared-state entries are accumulated in memory and written once after the loop using three new bulk helpers:
| Helper | Writes |
|---|---|
Storage::add_issuer_attestations_bulk |
1 read + 1 write |
Storage::increment_issuer_stats |
1 read + 1 write |
Storage::increment_total_attestations |
1 read + 1 write |
Batch of 50 — after:
- Issuer index: 1 read + 1 write
- Issuer stats: 1 read + 1 write
- Global stats: 1 read + 1 write
- Total: 3 reads + 3 writes
| Batch size | Writes before | Writes after | Saved |
|---|---|---|---|
| 10 | 30 | 3 | 27 |
| 50 | 150 | 3 | 147 |
| 100 | 300 | 3 | 297 |
The per-attestation writes (attestation record, subject index, audit log) are unchanged — only the shared-state writes are batched.
Run the before/after comparison:
cargo test bench_batch -- --nocaptureExpected output (approximate):
[bench_batch_50_correctness] PASS — 50 attestations, issuer index consistent
[bench_batch_50_write_reduction] batch=50 | cpu_instructions=... | memory_bytes=...
[bench_single_vs_batch_50]
single×50 : cpu=<higher> mem=<higher>
batch×50 : cpu=<lower> mem=<lower>
cpu saved : <N> (~X%)
- High Impact: Cron job to prune expired/revoked from indices (Vec shrink).
- Med: Use u128 IDs vs String (~20% storage save).
- Med: Cache hot
has_valid_claimin temp storage (instance()). - Low: Bump alloc for known index sizes.
- Future: Multisig batch writes for fee efficiency.
Benchmark code: benches/performance.rs. Run cargo test benches:: --nocapture.
Tested on Soroban SDK local (Linux x64, Rust 1.75). Live network CU ~10-20% higher.