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.
Detailed documentation for this contract is available in the docs/ directory:
- 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
The contract stores a single ProgramData structure containing:
program_id: Unique identifier for the program/hackathontotal_funds: Total amount of funds lockedremaining_balance: Current available balanceauthorized_payout_key: Address authorized to trigger payouts (backend)payout_history: Vector of all payout recordstoken_address: Address of the token contract for transfers
Initialize a new program escrow.
Parameters:
program_id: String identifier for the programauthorized_payout_key: Address that can trigger payoutstoken_address: Address of the token contract to use
Returns: ProgramData
Events: ProgramInitialized
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
Transfer funds to a single recipient. Requires authorization.
Parameters:
recipient: Address of the recipientamount: i128 amount to transfer (must be > 0)
Returns: Updated ProgramData
Events: Payout
Validation:
- Only
authorized_payout_keycan call this function - Amount must be > 0
- Sufficient balance must be available
Transfer funds to multiple recipients in a single transaction. Requires authorization.
Parameters:
recipients: Vec of recipient addressesamounts: Vec of amounts (must match recipients length)
Returns: Updated ProgramData
Events: BatchPayout
Validation:
- Only
authorized_payout_keycan 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.
View function to retrieve all program information.
Returns: ProgramData
View function to get the current remaining balance.
Returns: i128
Create a time-based release that can be executed once the ledger timestamp reaches the schedule timestamp.
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
Emitted when a program is initialized.
(ProgramInit, program_id, authorized_payout_key, token_address, total_funds)
Emitted when funds are locked into the escrow.
(FundsLocked, program_id, amount, remaining_balance)
Emitted when a single payout is executed.
(Payout, program_id, recipient, amount, remaining_balance)
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.
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()emits exactly one contract-level batch event per call.- Gas proxy counters are emitted in
BatchPayout:gas_proxy_transfer_ops = recipient_countgas_proxy_history_appends = recipient_countgas_proxy_storage_reads = 1gas_proxy_storage_writes = 1gas_proxy_events_emitted = 1
- Payout loop avoids indexed recipient/amount reads and iterates pairwise, reducing per-item overhead for large batches.
The test suite includes:
test_batch_payout_stress_large_batch_event_footprint_is_boundedtest_batch_payout_gas_proxy_improves_vs_legacy_model_for_large_batchbudget_profiling_batch_payout_scales_linearly_to_max_batch_sizebudget_profiling_single_payout_and_trigger_releases_stay_under_regression_ceilingbudget_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.
Run the profiling guard with:
cargo test budget_profiling -- --nocaptureCurrent 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.
- Initialize Program: Call
init_program()with program ID, authorized key, and token address - Lock Funds: Call
lock_program_funds()to deposit funds (can be called multiple times) - Execute Payouts: Call
single_payout()orbatch_payout()to distribute funds - Monitor: Use
get_program_info()orget_remaining_balance()to check status
- Only the
authorized_payout_keycan 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()minimizes storage churn by mutating in-memoryProgramDataand persisting once.- Payout loop reuses batch invariants (
batch_len, threshold, token client) to reduce repeated host work. - Event footprint stays predictable:
- Exactly one
BatchPayand oneAggStatscontract event per batch call. LrgPayevents are threshold-gated and mathematically bounded by payout constraints.
- Exactly one
- Gas-proxy tests for large batches live in
src/test.rsand assert event-growth and footprint bounds.
Run tests with:
cargo test --target wasm32-unknown-unknownBuild the contract with:
soroban contract buildDeploy using Soroban CLI:
soroban contract deploy \
--wasm target/wasm32-unknown-unknown/release/program_escrow.wasm \
--source <your-account> \
--network <network>The backend should:
- Initialize the contract with the backend's authorized key
- Monitor events for program state changes
- Call
batch_payout()after computing final scores and verifying KYC - Track payout history for audit purposes
// 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);