Skip to main content

Payout History Recipient Index

Last updated: 16 August 2026

Documents a contract that does not exist

The only Soroban contract in this project is grainhack-escrow (initialise, fund, publish_root, claim, is_claimed, sweep), and it has never been deployed. The program-escrow and bounty-escrow surfaces this page describes — publish_program, lock_program_funds, single_payout, batch_payout, create_bounty and the rest — have no implementation in any repository, verified 16 August 2026.

This page is kept as a design record. Do not read it as a description of working software, and do not implement against it without checking what the contract actually exposes. See Payout path — what exists today.

Issue: #1384
Status: Implemented

Problem​

PayoutRecord entries were stored only in a sequential list on ProgramData.payout_history (instance storage). Recipient-specific lookups required a full scan of all payout records — O(n) in the total number of payouts across all recipients.

Solution​

A lazy-initialized inverted index in persistent storage:

DataKey::RecipientPayoutIndex(program_id: String, recipient: Address)
→ Vec<PayoutRecord>

The key is only created on the first payout to that recipient. Programs with no payouts to a given address pay zero cold-storage cost (the key simply does not exist).

Storage Choice​

AspectDecision
Storage tierpersistent — survives instance TTL eviction
Key typeDataKey::RecipientPayoutIndex(program_id, recipient)
Value typeVec<PayoutRecord> — appended on every payout
Init costZero until first payout to each recipient

Write Paths​

Both payout execution paths now write to the index after the token transfer and sequential log update, in the same transaction:

  • single_payout_internal — calls append_recipient_index once
  • batch_payout_internal — calls append_recipient_index once per recipient in the loop

trigger_program_releases (scheduled releases) is intentionally out of scope for this issue.

Read Path​

program-escrow: query_recipient_history​

pub fn query_recipient_history(
env: Env,
program_id: String,
recipient: Address,
) -> Vec<PayoutRecord>

O(1) lookup — reads DataKey::RecipientPayoutIndex(program_id, recipient) from persistent storage. Returns an empty Vec for unknown recipients.

view-facade: query_recipient_history​

pub fn query_recipient_history(
env: Env,
escrow: Address,
program_id: String,
recipient: Address,
) -> Vec<PayoutRecord>

Delegates to the program-escrow contract at escrow via cross-contract call. The caller is responsible for supplying a registered ProgramEscrow address (verifiable via ViewFacade::get_contract).

Complexity​

OperationBeforeAfter
Lookup by recipientO(n) full scanO(1) index read
Write (single payout)O(1) append to listO(1) + 1 persistent write
Write (batch, k recipients)O(k) appendsO(k) + k persistent writes

Security Notes​

  • The index is written exclusively by authenticated payout paths — an unprivileged caller cannot inject fake records.
  • query_recipient_history is read-only; no authorization required.
  • The program_id parameter in the view-facade call is caller-supplied but cannot forge records: the key written during a payout uses the program_id stored in ProgramData, not a caller-controlled value.
  • Persistent storage entries for the index are separate from instance storage, so they survive contract upgrades.

Tests​

contracts/program-escrow/src/recipient_index_tests.rs — 9 tests:

  1. Unknown recipient returns empty (lazy init)
  2. single_payout writes index
  3. Multiple single_payout calls accumulate in order
  4. batch_payout writes index for each recipient
  5. Mixed single + batch accumulate correctly
  6. query_recipient_history agrees with legacy query_payouts_by_recipient
  7. Index is scoped to program_id (no cross-program leakage)
  8. Unrelated recipients not affected
  9. Timestamps are recorded