Skip to main content

Program Escrow — Archived Payout History Storage Tiering

Issue: Migrate archived program payout_history to the persistent storage tier on archive_program Contract: contracts/program-escrow/src/lib.rs


Problem

archive_program and get_archived_programs marked a program as archived but left its full payout_history: Vec<PayoutRecord> in the same instance-storage entry as active programs.

Instance storage in Soroban is TTL-extended on every contract invocation and contributes to the per-invocation rent cost. Archived programs are read-only and rarely accessed, so keeping their payout history in the hot instance tier was both wasteful and costly:

  • Every contract call extended the TTL on potentially large Vec<PayoutRecord> data that would never change again.
  • As the number of archived programs grew, so did the instance-storage footprint — increasing rent for all users of the contract.

Solution

On archive_program, migrate payout_history out of the ProgramData instance-storage entry into Soroban's persistent-storage tier, then clear the inline vector.

Storage key added

// DataKey enum — contracts/program-escrow/src/lib.rs
ArchivedPayoutHistory(String), // program_id → Vec<PayoutRecord> (persistent)

archive_program — before

program_data.archived = true;
program_data.archived_at = Some(env.ledger().timestamp());
env.storage().instance().set(&program_key, &program_data);
// payout_history remains in instance storage untouched

archive_program — after

// 1. Write history to persistent tier (once; guarded against double-archival)
let history_key = DataKey::ArchivedPayoutHistory(program_id.clone());
if !env.storage().persistent().has(&history_key) {
env.storage().persistent().set(&history_key, &program_data.payout_history);
}

// 2. Clear inline vector to shrink the instance-storage entry
program_data.payout_history = soroban_sdk::Vec::new(&env);

// 3. Mark archived and persist the now-smaller ProgramData
program_data.archived = true;
program_data.archived_at = Some(env.ledger().timestamp());
env.storage().instance().set(&program_key, &program_data);

New read function

/// Return the full payout history for an archived program.
pub fn get_archived_program_payout_history(
env: Env,
program_id: String,
) -> soroban_sdk::Vec<PayoutRecord>

Reads directly from DataKey::ArchivedPayoutHistory in persistent storage. Returns an empty Vec if the program has not been archived or had no payouts.


Storage tier comparison

TierWhen to useRent modelTTL behaviour
InstanceHot data accessed on every callExtended on every invocationResets on each call
PersistentCold / archival / index dataExtended explicitly or via bumpDecays unless bumped

Archived payout histories are by definition read-only and rarely accessed, making persistent storage the correct home for them.


Impact on existing query paths

query_recipient_history

Unchanged. The per-recipient inverted index (DataKey::RecipientPayoutIndex) was already in persistent storage before this change. It is written by append_recipient_index on every payout and is unaffected by archival. Callers of query_recipient_history continue to work transparently for archived programs.

get_program_info_v2 / ProgramData.payout_history

After archival this field returns an empty Vec. Consumers that need the full history of an archived program should call get_archived_program_payout_history instead.

get_program_aggregate_stats

This function reads from the legacy singleton PROGRAM_DATA key and its payout_history field. If the singleton program is archived, payout_history will be empty post-migration. The persistent tier holds the authoritative history via get_archived_program_payout_history.


Double-archival safety

archive_program guards the migration write with:

if !env.storage().persistent().has(&history_key) {
env.storage().persistent().set(&history_key, &program_data.payout_history);
}

Calling archive_program a second time will not overwrite the already-migrated history with the now-empty inline Vec.


Security notes

  • Admin-only: archive_program continues to require admin authorisation via require_admin.
  • Immutability: The persistent history key is written exactly once and never mutated after archival. There is no code path that modifies ArchivedPayoutHistory post-write.
  • No trust escalation: get_archived_program_payout_history is read-only and requires no authorisation — consistent with the existing query_recipient_history design (payout records are public on-chain data).
  • No data loss: The migration copies the full Vec<PayoutRecord> to persistent storage before clearing the inline field. Tests confirm byte-for-byte equivalence.

Tests

Tests live in contracts/program-escrow/src/test_archival.rs.

TestDescription
test_program_archival_successBasic archive flag and registry
test_archive_migrates_payout_history_to_persistent_storageHistory present in persistent tier, cleared in instance tier
test_get_archived_program_payout_history_returns_correct_recordsQuery function returns correct records
test_query_recipient_history_works_for_archived_programPer-recipient index unaffected by archival
test_double_archival_is_idempotent_and_preserves_historySecond archive_program call does not erase history
test_archive_program_with_no_payoutsZero-payout programs archive cleanly
test_archive_requires_adminNon-admin call panics
test_archive_non_existent_programMissing program panics with "Program not found"
test_instance_storage_footprint_shrinks_after_archivalN archived programs have empty payout_history in instance storage; persistent tier holds all records
test_program_archival_filteringlist_programs filters archived entries
test_no_data_loss_after_archivalTotal amount and per-recipient counts identical before and after archival

Run with:

cargo test -p program-escrow

Files changed

FileChange
contracts/program-escrow/src/lib.rsAdded DataKey::ArchivedPayoutHistory; updated archive_program to migrate history; added get_archived_program_payout_history
contracts/program-escrow/src/test_archival.rsComprehensive tests for migration, data integrity, and footprint
docs/program-escrow-archival-storage-tiering.mdThis document