Skip to main content

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)

ResourceCost
Per XDR byte written1 unit of IoWrite (≈ 0.05 XLM at 1e5 base fee)
Per XDR byte read1 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 variantLegacy stringXDR 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)+4len(s)+40

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_metadata and update_program_metadata.
  • Reads: get_program_metadata reads MetadataV2 first and decompresses on the fly. If MetadataV2 does not exist, falls back to Metadata (legacy).
  • Public API unchanged: get_program_metadata still returns Option<ProgramMetadata> with String keys. 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:

  1. Checks DataKey::MetadataV2 — if present, decompresses and returns.
  2. Falls back to DataKey::Metadata — reads the legacy ProgramMetadata directly.

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:

MetricValue
Legacy size≈ 560–620 bytes
Compressed size≈ 480–520 bytes
Savings≈ 14–18%

Max-keys benchmark (10 known + 2 custom)

MetricValue
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 CompressedProgramMetadata under MetadataV2.
  • Existing programs with metadata under Metadata continue to work via the fallback read path.
  • When update_program_metadata is called on a legacy program, the compressed format is written to MetadataV2 (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

  1. 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.
  2. Custom keys carry 4 bytes of overhead (the enum discriminant) compared to a raw String key stored directly. For programs with very few known keys this is a minor regression.
  3. 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

ConcernStatus
Loss of supported metadataNone. All string keys are preserved via Custom(String)
Deterministic encodingConfirmed. Same input → same output always
Safe handling of unknown/custom keysfrom_string maps any unrecognised string to Custom(s) without truncation or modification
Compatibility with existing contractsFull backwards compatibility via dual-key storage and fallback read path
Case sensitivityMatching is case-sensitive. "Sponsor""sponsor". The Custom variant handles non-matching casing
Empty keysEmpty strings are mapped to Custom("") — no panic, no data loss
Long keysNo length limit on custom keys. The Soroban String type handles arbitrary lengths

Testing

Unit tests (in metadata.rs)

TestCoverage
test_known_key_round_tripsAll 10 known keys round-trip through from_string/to_legacy_string
test_custom_key_preservedCustom keys map to Custom variant and round-trip
test_empty_key_is_customEmpty string → Custom("")
test_case_sensitiveUppercase known key → Custom (not matched)
test_compress_decompress_round_tripFull ProgramMetadataCompressedProgramMetadata → back
test_compress_empty_custom_fieldsZero custom fields round-trip
test_compress_mixed_keysKnown + custom keys round-trip
test_metadata_field_key_val_roundtripAll variants through Soroban Val XDR
test_backwards_compatible_roundtripLegacy constructor pattern compresses and decompresses
test_compress_large_custom_key256-char key preserved
test_compress_special_charactersKeys with underscores, numbers

Integration tests (in test_metadata_tagging.rs)

TestCoverage
test_compress_known_keys_through_storageKnown keys stored and retrieved via contract
test_compress_mixed_keys_through_storageMixed known/custom through contract
test_compress_empty_custom_fields_through_storageEmpty fields through contract
test_compress_round_trip_through_updateCompressed metadata survives update_program_metadata
test_legacy_metadata_still_readableLegacy DataKey::Metadata still readable
test_compress_long_custom_key200-char key through contract
test_compress_special_chars_in_keyUnderscores, hyphens, dots
test_compress_case_sensitivityCase mismatch does not compress

Benchmarks (in benches/metadata_compression_benchmark.rs)

BenchmarkMeasures
benchmark_metadata_compressionRepresentative payload: legacy vs compressed size
benchmark_compression_max_keysAll 10 known + 2 custom
benchmark_compression_custom_onlyWorst case: all custom keys
benchmark_empty_custom_fieldsEmpty fields baseline

Files Changed

FileChange
contracts/program-escrow/src/metadata.rsRewritten: MetadataFieldKey, CompressedCustomField, CompressedProgramMetadata, conversion helpers
contracts/program-escrow/src/lib.rsAdded mod metadata;, DataKey::MetadataV2, updated read/write paths
contracts/program-escrow/src/test_metadata_tagging.rsAdded compression integration tests (8 new)
contracts/program-escrow/benches/metadata_compression_benchmark.rsNew: size comparison benchmarks
docs/program-escrow-metadata-compression.mdThis document