Skip to main content

Recipient Payout Index Consistency

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.

Problem​

query_payouts_by_recipient and get_payouts_by_recipient scan the entire payout_history vector with a per-record filter (record.recipient == target). This is O(n) in the total number of payouts across all recipients, degrading as history grows.

Solution: Lazy Inverted Index​

A new DataKey::RecipientPayoutIndex(String, Address) → Vec<PayoutRecord> entry in persistent storage:

  • Keyed by (program_id, recipient) so each recipient has its own compact list
  • Written by append_recipient_index() called inside single_payout_internal and batch_payout_internal
  • Lazily initialized — no storage entry exists until the first payout to that recipient
  • Read by query_recipient_history(env, program_id, recipient) which is O(1)

Consistency Guarantees​

PropertyMechanism
At-most-once per payoutIndex written inside the same atomic execution as the payout transfer; a replay via idempotency key returns early with no duplicate append
Insertion orderappend_recipient_index pushes to the back of the Vec; both single_payout_internal and batch_payout_internal execute payouts in chronological order
Cross-program isolationKey includes program_id, so payouts in program A never leak into program B's index
Parity with legacy queryquery_recipient_history returns the exact same records as query_payouts_by_recipient filtered to that recipient, confirmed by test test_index_matches_legacy_filtered_query

Test Coverage​

All tests live in contracts/program-escrow/src/recipient_index_tests.rs:

  1. test_unknown_recipient_returns_empty — lazy init, no panic
  2. test_single_payout_writes_index — basic single payout
  3. test_single_payout_accumulates_in_order — multiple singles, same recipient
  4. test_batch_payout_writes_index_for_each_recipient — batch, multiple recipients
  5. test_single_and_batch_payout_accumulate — interleaved single + batch
  6. test_index_matches_legacy_filtered_query — parity with query_payouts_by_recipient
  7. test_index_scoped_to_program_id — no cross-program leakage
  8. test_unrelated_recipient_index_stays_empty — unrelated recipient unaffected
  9. test_index_records_timestamp — timestamp captured in index entry
  10. test_idempotent_replay_does_not_duplicate_index — idempotent replay safety
  11. test_batch_idempotent_replay_does_not_duplicate_index — batch idempotent replay safety
  12. test_index_returns_all_records_beyond_limit — index returns full history beyond pagination window
  13. test_interleaved_payouts_across_recipients — complex interleaved payout sequences
  14. test_multiple_batches_to_same_recipient — repeated batch payouts to same recipient
  15. test_all_entrypoints_interleaved_to_same_recipient — all four entrypoints (single, batch, idem-single, idem-batch) interleaved to the same recipient, with idempotent replay verification

Facade Proxy​

EscrowViewFacade::query_recipient_history in contracts/escrow-view-facade/ exposes the read to off-chain consumers:

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

This follows the existing facade pattern: it returns an empty Vec on cross-contract error rather than trapping, making it safe for frontend consumption.