Skip to content

Latest commit

 

History

History
 
 

README.md

Program Escrow Contract

A Soroban smart contract for managing program-level escrow funds for hackathons and grant programs. This contract handles prize pools, tracks balances, and enables automated batch payouts to multiple contributors.

Documentation

Detailed documentation for this contract is available in the docs/ directory:

Features

  • Program Initialization: Create a new escrow program with authorized payout key
  • Fund Locking: Lock funds into the escrow (tracks total and remaining balance)
  • Single Payout: Transfer funds to a single recipient
  • Batch Payout: Transfer funds to multiple recipients in a single transaction
  • Release Schedules (Vesting): Queue timestamp-based releases and execute them when due
  • Balance Tracking: Accurate tracking of total funds and remaining balance
  • Authorization: Only authorized payout key can trigger payouts
  • Event Emission: All operations emit events for off-chain tracking
  • Payout History: Maintains a complete history of all payouts

Contract Structure

Storage

The contract stores a single ProgramData structure containing:

  • program_id: Unique identifier for the program/hackathon
  • total_funds: Total amount of funds locked
  • remaining_balance: Current available balance
  • authorized_payout_key: Address authorized to trigger payouts (backend)
  • payout_history: Vector of all payout records
  • token_address: Address of the token contract for transfers

Functions

init_program(program_id, authorized_payout_key, token_address)

Initialize a new program escrow.

Parameters:

  • program_id: String identifier for the program
  • authorized_payout_key: Address that can trigger payouts
  • token_address: Address of the token contract to use

Returns: ProgramData

Events: ProgramInitialized

lock_program_funds(from, amount)

Lock funds into the escrow. Updates both total_funds and remaining_balance.

Parameters:

  • amount: i128 amount to lock (must be > 0)

Returns: Updated ProgramData

Events: FundsLocked

single_payout(recipient, amount)

Transfer funds to a single recipient. Requires authorization.

Parameters:

  • recipient: Address of the recipient
  • amount: i128 amount to transfer (must be > 0)

Returns: Updated ProgramData

Events: Payout

Validation:

  • Only authorized_payout_key can call this function
  • Amount must be > 0
  • Sufficient balance must be available

batch_payout(recipients, amounts)

Transfer funds to multiple recipients in a single transaction. Requires authorization.

Parameters:

  • recipients: Vec of recipient addresses
  • amounts: Vec of amounts (must match recipients length)

Returns: Updated ProgramData

Events: BatchPayout

Validation:

  • Only authorized_payout_key can call this function
  • Recipients and amounts vectors must have same length
  • recipients.len() must be in the range [1, MAX_BATCH_SIZE] (currently 100)
  • All amounts must be > 0
  • Total payout must not exceed remaining balance
  • Cannot process empty batch

Batch size cap (MAX_BATCH_SIZE = 100): The cap is enforced before any token transfer. Calls with zero recipients or more than 100 recipients panic with a clear message and clear the reentrancy guard, so no partial payout or stuck guard can occur.

get_program_info()

View function to retrieve all program information.

Returns: ProgramData

get_remaining_balance()

View function to get the current remaining balance.

Returns: i128

create_program_release_schedule(recipient, amount, release_timestamp)

Create a time-based release that can be executed once the ledger timestamp reaches the schedule timestamp.

trigger_program_releases()

Execute due release schedules where ledger_timestamp >= release_timestamp. At most MAX_BATCH_SIZE (100) schedules are processed per invocation to bound Soroban instruction and memory usage. Remaining due schedules can be processed by calling again.

Edge-case behavior validated in tests:

  • Exact boundary is accepted: release executes when now == release_timestamp
  • Early execution is rejected: no release when now < release_timestamp
  • Late execution is accepted: pending releases execute when now >> release_timestamp
  • Overlapping schedules are supported: multiple due schedules execute in the same trigger call

Events

ProgramInitialized

Emitted when a program is initialized.

(ProgramInit, program_id, authorized_payout_key, token_address, total_funds)

FundsLocked

Emitted when funds are locked into the escrow.

(FundsLocked, program_id, amount, remaining_balance)

Payout

Emitted when a single payout is executed.

(Payout, program_id, recipient, amount, remaining_balance)

BatchPayout

Emitted when a batch payout is executed.

(BatchPayout, program_id, recipient_count, total_amount, remaining_balance,
 gas_proxy_transfer_ops, gas_proxy_history_appends,
 gas_proxy_storage_reads, gas_proxy_storage_writes, gas_proxy_events_emitted)

Gas proxy fields are lightweight instrumentation for payout profiling. They track high-level operation counts without adding per-recipient events, keeping event footprint bounded for large batches.

Batch Size Cap

MAX_BATCH_SIZE = 100 is enforced in two places:

Function Behaviour
batch_payout Panics "Batch size exceeds maximum allowed" before any transfer if recipients.len() > 100
trigger_program_releases Processes at most 100 due schedules per call; remaining due schedules are left for the next call

This keeps instruction count and payout_history growth deterministic and bounded. The constant is defined once (pub const MAX_BATCH_SIZE: u32 = 100) and shared across all three functions.

Batch Payout Gas and Footprint Notes

  • batch_payout() emits exactly one contract-level batch event per call.
  • Gas proxy counters are emitted in BatchPayout:
    • gas_proxy_transfer_ops = recipient_count
    • gas_proxy_history_appends = recipient_count
    • gas_proxy_storage_reads = 1
    • gas_proxy_storage_writes = 1
    • gas_proxy_events_emitted = 1
  • Payout loop avoids indexed recipient/amount reads and iterates pairwise, reducing per-item overhead for large batches.

Stress/Gas Validation

The test suite includes:

  • test_batch_payout_stress_large_batch_event_footprint_is_bounded
  • test_batch_payout_gas_proxy_improves_vs_legacy_model_for_large_batch
  • budget_profiling_batch_payout_scales_linearly_to_max_batch_size
  • budget_profiling_single_payout_and_trigger_releases_stay_under_regression_ceiling
  • budget_profiling_gas_proxy_fields_match_operation_counts

These validate bounded event growth, improved proxy metrics for large batches, and real Soroban env.budget() CPU/memory regression ceilings.

Budget Profiling

Run the profiling guard with:

cargo test budget_profiling -- --nocapture

Current native testutils measurements, excluding setup/mint/init cost:

Operation Size CPU instructions Memory bytes
batch_payout 1 355,276 54,175
batch_payout 10 1,877,730 277,195
batch_payout 50 9,889,779 1,658,435
batch_payout 100 (MAX_BATCH_SIZE) 22,491,709 4,280,485
single_payout 1 345,503 53,351
trigger_program_releases 10 due schedules 2,370,396 385,935

The regression test resets env.budget() immediately before each measured call, then asserts CPU and memory stay below documented ceilings. The Soroban SDK notes that native Rust test execution can underestimate WASM costs, so these numbers are CI regression guards rather than mainnet fee quotes.

Usage Flow

  1. Initialize Program: Call init_program() with program ID, authorized key, and token address
  2. Lock Funds: Call lock_program_funds() to deposit funds (can be called multiple times)
  3. Execute Payouts: Call single_payout() or batch_payout() to distribute funds
  4. Monitor: Use get_program_info() or get_remaining_balance() to check status

Security Considerations

  • Only the authorized_payout_key can trigger payouts
  • Balance validation prevents over-spending
  • All amounts must be positive
  • Payout history is immutable and auditable
  • Token transfers use the Soroban token contract standard

Batch Payout Gas and Footprint Notes

  • batch_payout() minimizes storage churn by mutating in-memory ProgramData and persisting once.
  • Payout loop reuses batch invariants (batch_len, threshold, token client) to reduce repeated host work.
  • Event footprint stays predictable:
    • Exactly one BatchPay and one AggStats contract event per batch call.
    • LrgPay events are threshold-gated and mathematically bounded by payout constraints.
  • Gas-proxy tests for large batches live in src/test.rs and assert event-growth and footprint bounds.

Testing

Run tests with:

cargo test --target wasm32-unknown-unknown

Building

Build the contract with:

soroban contract build

Deployment

Deploy using Soroban CLI:

soroban contract deploy \
  --wasm target/wasm32-unknown-unknown/release/program_escrow.wasm \
  --source <your-account> \
  --network <network>

Integration with Backend

The backend should:

  1. Initialize the contract with the backend's authorized key
  2. Monitor events for program state changes
  3. Call batch_payout() after computing final scores and verifying KYC
  4. Track payout history for audit purposes

Example

// Initialize
let program_data = contract.init_program(
    &env,
    String::from_str(&env, "stellar-hackathon-2024"),
    backend_address,
    token_address
);

// Lock funds (50,000 XLM in stroops)
contract.lock_program_funds(&env, &admin, &50_000_000_000);

// Batch payout to winners
let recipients = vec![&env, winner1, winner2, winner3];
let amounts = vec![&env, 20_000_000_000, 15_000_000_000, 10_000_000_000];
contract.batch_payout(&env, recipients, amounts);

// Check remaining balance
let balance = contract.get_remaining_balance(&env);