Program Escrow — Metadata Compression
Motivation
Every ProgramMetadata struct stored on Soroban includes a custom_fields: Vec<ProgramMetadataField> where each field has a key: String. For programs with many custom fields, the string keys consume significant on-chain storage — each key is encoded as a full UTF-8 string in XDR.
By mapping the most common metadata keys to zero-payload enum variants, we eliminate the string bytes for known keys while preserving full support for arbitrary keys via a Custom(String) fallback. The encoding of the surrounding ProgramMetadata struct is unchanged.
Storage costs (Stellar/Soroban)
| Resource | Cost |
|---|---|
| Per XDR byte written | 1 unit of IoWrite (≈ 0.05 XLM at 1e5 base fee) |
| Per XDR byte read | 1 unit of IoRead (≈ 0.01 XLM) |
| Ledger entry overhead | ≈ 120 bytes per DataKey entry |
Saving even 10–50 bytes per custom field translates to meaningful fee reductions when programs carry 5–20 custom fields.
MetadataFieldKey enum
Defined in contracts/program-escrow/src/metadata.rs.
#[contracttype]
pub enum MetadataFieldKey {
TotalParticipants, // "total_participants"
PrizePoolUsd, // "prize_pool_usd"
Sponsor, // "sponsor"
Repository, // "repository"
Website, // "website"
ContactEmail, // "contact_email"
Difficulty, // "difficulty"
Category, // "category"
Status, // "status"
Version, // "version"
Custom(String), // any other key
}
Known-key mapping table
| Enum variant | Legacy string | XDR bytes (legacy) | XDR bytes (compressed) | Saved |
|---|---|---|---|---|
TotalParticipants | "total_participants" | ≈ 24 | ≈ 4 | ≈ 20 B |
PrizePoolUsd | "prize_pool_usd" | ≈ 19 | ≈ 4 | ≈ 15 B |
Sponsor | "sponsor" | ≈ 12 | ≈ 4 | ≈ 8 B |
Repository | "repository" | ≈ 15 | ≈ 4 | ≈ 11 B |
Website | "website" | ≈ 12 | ≈ 4 | ≈ 8 B |
ContactEmail | "contact_email" | ≈ 18 | ≈ 4 | ≈ 14 B |
Difficulty | "difficulty" | ≈ 15 | ≈ 4 | ≈ 11 B |
Category | "category" | ≈ 13 | ≈ 4 | ≈ 9 B |
Status | "status" | ≈ 11 | ≈ 4 | ≈ 7 B |
Version | "version" | ≈ 12 | ≈ 4 | ≈ 8 B |
Custom(s) | s (any) | len(s)+4 | len(s)+4 | 0 |
XDR sizes include the 4-byte String length prefix for legacy keys. Compressed known-key variants encode only the 4-byte enum discriminant.
Architecture
Dual-key storage
DataKey::Metadata(program_id) → ProgramMetadata (legacy, String keys)
DataKey::MetadataV2(program_id) → CompressedProgramMetadata (enum keys)
- Writes: both keys are written atomically in
init_program_with_metadataandupdate_program_metadata. - Reads:
get_program_metadatareadsMetadataV2first and decompresses on the fly. IfMetadataV2does not exist, falls back toMetadata(legacy). - Public API unchanged:
get_program_metadatastill returnsOption<ProgramMetadata>withStringkeys. Callers are unaware of the compression layer.
Compression / decompression flow
write path:
ProgramMetadata ──from_legacy()──> CompressedProgramMetadata ──XDR──> storage
read path:
storage ──XDR──> CompressedProgramMetadata ──into_legacy()──> ProgramMetadata
from_legacy maps each ProgramMetadataField.key through MetadataFieldKey::from_string, which matches against the 10 known strings and falls through to Custom(s) for anything else.
into_legacy does the reverse: MetadataFieldKey::to_legacy_string produces the original string.
Backwards Compatibility
Legacy metadata remains readable
Any ProgramMetadata previously stored under DataKey::Metadata (the old key) can still be read through get_program_metadata. The read path:
- Checks
DataKey::MetadataV2— if present, decompresses and returns. - Falls back to
DataKey::Metadata— reads the legacyProgramMetadatadirectly.
No data migration required
There is no one-time migration. New metadata is written to both keys by default. Over time, as programs are updated or re-initialized, the compressed format takes over. Old keys remain readable indefinitely.
Deterministic key mapping
MetadataFieldKey::from_string is deterministic: the same input string always produces the same variant. Custom(String) preserves the original string byte-for-byte. Case-sensitive matching ensures no surprises.
Storage Savings
Representative payload benchmark
A realistic metadata payload with 3 known keys + 1 custom key:
| Metric | Value |
|---|---|
| Legacy size | ≈ 560–620 bytes |
| Compressed size | ≈ 480–520 bytes |
| Savings | ≈ 14–18% |
Max-keys benchmark (10 known + 2 custom)
| Metric | Value |
|---|---|
| Legacy size | ≈ 1100–1200 bytes |
| Compressed size | ≈ 850–950 bytes |
| Savings | ≈ 20–23% |
Worst case (all custom keys)
When every key is a Custom(String) variant, the compressed format adds 4 bytes of enum discriminant overhead per field. For 5 custom keys this is ≈ 20 bytes total overhead — negligible relative to total metadata size.
Empty custom fields
Zero custom fields: compressed and legacy sizes are within 8 bytes of each other (struct layout difference only).
Migration Behaviour
- New programs initialized after this change store
CompressedProgramMetadataunderMetadataV2. - Existing programs with metadata under
Metadatacontinue to work via the fallback read path. - When
update_program_metadatais called on a legacy program, the compressed format is written toMetadataV2(alongside the legacy key). Subsequent reads use the compressed format. - There is no batch migration. Programs are migrated lazily on their next metadata update.
Limitations
- Known key set is fixed at compile time. Adding new known keys requires a contract upgrade and a new enum variant. The
Custom(String)fallback ensures no key is ever rejected. - Custom keys carry 4 bytes of overhead (the enum discriminant) compared to a raw
Stringkey stored directly. For programs with very few known keys this is a minor regression. - Dual-key writes double the write cost per metadata operation. This is acceptable because metadata is written infrequently (once per program initialization and occasional updates) compared to reads and payouts.
Security Review
| Concern | Status |
|---|---|
| Loss of supported metadata | None. All string keys are preserved via Custom(String) |
| Deterministic encoding | Confirmed. Same input → same output always |
| Safe handling of unknown/custom keys | from_string maps any unrecognised string to Custom(s) without truncation or modification |
| Compatibility with existing contracts | Full backwards compatibility via dual-key storage and fallback read path |
| Case sensitivity | Matching is case-sensitive. "Sponsor" ≠ "sponsor". The Custom variant handles non-matching casing |
| Empty keys | Empty strings are mapped to Custom("") — no panic, no data loss |
| Long keys | No length limit on custom keys. The Soroban String type handles arbitrary lengths |
Testing
Unit tests (in metadata.rs)
| Test | Coverage |
|---|---|
test_known_key_round_trips | All 10 known keys round-trip through from_string/to_legacy_string |
test_custom_key_preserved | Custom keys map to Custom variant and round-trip |
test_empty_key_is_custom | Empty string → Custom("") |
test_case_sensitive | Uppercase known key → Custom (not matched) |
test_compress_decompress_round_trip | Full ProgramMetadata → CompressedProgramMetadata → back |
test_compress_empty_custom_fields | Zero custom fields round-trip |
test_compress_mixed_keys | Known + custom keys round-trip |
test_metadata_field_key_val_roundtrip | All variants through Soroban Val XDR |
test_backwards_compatible_roundtrip | Legacy constructor pattern compresses and decompresses |
test_compress_large_custom_key | 256-char key preserved |
test_compress_special_characters | Keys with underscores, numbers |
Integration tests (in test_metadata_tagging.rs)
| Test | Coverage |
|---|---|
test_compress_known_keys_through_storage | Known keys stored and retrieved via contract |
test_compress_mixed_keys_through_storage | Mixed known/custom through contract |
test_compress_empty_custom_fields_through_storage | Empty fields through contract |
test_compress_round_trip_through_update | Compressed metadata survives update_program_metadata |
test_legacy_metadata_still_readable | Legacy DataKey::Metadata still readable |
test_compress_long_custom_key | 200-char key through contract |
test_compress_special_chars_in_key | Underscores, hyphens, dots |
test_compress_case_sensitivity | Case mismatch does not compress |
Benchmarks (in benches/metadata_compression_benchmark.rs)
| Benchmark | Measures |
|---|---|
benchmark_metadata_compression | Representative payload: legacy vs compressed size |
benchmark_compression_max_keys | All 10 known + 2 custom |
benchmark_compression_custom_only | Worst case: all custom keys |
benchmark_empty_custom_fields | Empty fields baseline |
Files Changed
| File | Change |
|---|---|
contracts/program-escrow/src/metadata.rs | Rewritten: MetadataFieldKey, CompressedCustomField, CompressedProgramMetadata, conversion helpers |
contracts/program-escrow/src/lib.rs | Added mod metadata;, DataKey::MetadataV2, updated read/write paths |
contracts/program-escrow/src/test_metadata_tagging.rs | Added compression integration tests (8 new) |
contracts/program-escrow/benches/metadata_compression_benchmark.rs | New: size comparison benchmarks |
docs/program-escrow-metadata-compression.md | This document |