Summary
Build a streaming stipend contract — a Soroban contract that releases scholarship funds continuously, ledger by ledger, instead of in lump-sum tranches. A scholar accrues their stipend in real time and withdraws whenever they want; a sponsor can pause, cancel, or top up, and unearned funds return to the treasury. New feature; contracts/milestone_escrow releases discrete tranches only, with no time-based accrual.
Background
Lump-sum tranches fit the funder's calendar, not the learner's life. A scholar who receives three months of funding at once has to budget it themselves under pressure; a sponsor who wants to stop funding an inactive scholar has already sent the money and cannot get it back.
Streaming fixes both sides at once. The scholar sees a balance that ticks upward while they study — a genuinely motivating thing to watch — and the sponsor's remaining capital stays reclaimable. Stellar's low fees are what make frequent small withdrawals actually viable; on a chain with meaningful gas this design does not work.
What to build
1. Soroban contract contracts/stipend_stream/
Storage per stream: sponsor, recipient, token, total_amount, start_ledger, end_ledger, withdrawn, status.
Functions:
create_stream(sponsor, recipient, token, amount, start_ledger, end_ledger) — transfers the full amount into the contract up front, so the stream is always fully collateralized. A stream that can run dry is not a stream.
withdrawable(stream_id) -> i128 — linear accrual:
elapsed = min(current_ledger, end_ledger) − start_ledger
earned = total_amount × elapsed / (end_ledger − start_ledger)
withdrawable = earned − withdrawn
Use checked arithmetic throughout; multiply before dividing to avoid truncation losses on small withdrawals.
withdraw(stream_id) — recipient-authorized, transfers withdrawable, increments withdrawn.
pause_stream / resume_stream — sponsor or DAO authorized. Accrual must stop while paused and the paused duration must extend end_ledger, otherwise a pause silently confiscates the scholar's time.
cancel_stream — pays the recipient everything earned to date, refunds the unearned remainder to the sponsor. This split is the contract's most important invariant.
top_up_stream — sponsor adds funds and extends end_ledger.
2. Rust tests
- Accrual at 0%, 50%, 100%, and past
end_ledger (must clamp, never exceed total_amount).
withdrawn + refunded == total_amount after cancel, for a range of cancellation points — assert this as an explicit invariant.
- Pause freezes accrual and shifts the end ledger by exactly the paused duration.
- Unauthorized
withdraw, cancel, and pause all panic.
- Repeated
withdraw calls in the same ledger cannot double-pay.
3. Backend
- Index
StreamCreated / Withdrawn / Cancelled events through the existing event indexer (see TODO.md — the poller and events table already exist).
- Migration
server/src/db/migrations/029_stipend_streams.sql plus .undo.sql mirroring stream state for fast queries.
GET /api/streams?recipient= and GET /api/streams/:id.
4. Frontend
src/hooks/useStipendStream.ts and a stream card on the scholar dashboard: a live-ticking withdrawable balance (interpolate between ledger closes client-side), total streamed, time remaining, and a Withdraw button.
- Sponsor view: create, pause, top up, cancel, with a clear preview of the earned/refunded split before cancelling is confirmed.
Acceptance criteria
Notes for contributors
- Follow the existing contract layout and test conventions in
contracts/milestone_escrow/.
- Ledger-based timing is preferred over timestamps for accrual math; if you use timestamps, justify it in the PR.
- Rounding is where streaming contracts leak value. State your rounding direction and prove with a test that the contract can never pay out more than
total_amount.
Summary
Build a streaming stipend contract — a Soroban contract that releases scholarship funds continuously, ledger by ledger, instead of in lump-sum tranches. A scholar accrues their stipend in real time and withdraws whenever they want; a sponsor can pause, cancel, or top up, and unearned funds return to the treasury. New feature;
contracts/milestone_escrowreleases discrete tranches only, with no time-based accrual.Background
Lump-sum tranches fit the funder's calendar, not the learner's life. A scholar who receives three months of funding at once has to budget it themselves under pressure; a sponsor who wants to stop funding an inactive scholar has already sent the money and cannot get it back.
Streaming fixes both sides at once. The scholar sees a balance that ticks upward while they study — a genuinely motivating thing to watch — and the sponsor's remaining capital stays reclaimable. Stellar's low fees are what make frequent small withdrawals actually viable; on a chain with meaningful gas this design does not work.
What to build
1. Soroban contract
contracts/stipend_stream/Storage per stream:
sponsor,recipient,token,total_amount,start_ledger,end_ledger,withdrawn,status.Functions:
create_stream(sponsor, recipient, token, amount, start_ledger, end_ledger)— transfers the full amount into the contract up front, so the stream is always fully collateralized. A stream that can run dry is not a stream.withdrawable(stream_id) -> i128— linear accrual:elapsed = min(current_ledger, end_ledger) − start_ledgerearned = total_amount × elapsed / (end_ledger − start_ledger)withdrawable = earned − withdrawnUse checked arithmetic throughout; multiply before dividing to avoid truncation losses on small withdrawals.
withdraw(stream_id)— recipient-authorized, transferswithdrawable, incrementswithdrawn.pause_stream/resume_stream— sponsor or DAO authorized. Accrual must stop while paused and the paused duration must extendend_ledger, otherwise a pause silently confiscates the scholar's time.cancel_stream— pays the recipient everything earned to date, refunds the unearned remainder to the sponsor. This split is the contract's most important invariant.top_up_stream— sponsor adds funds and extendsend_ledger.2. Rust tests
end_ledger(must clamp, never exceedtotal_amount).withdrawn + refunded == total_amountafter cancel, for a range of cancellation points — assert this as an explicit invariant.withdraw,cancel, andpauseall panic.withdrawcalls in the same ledger cannot double-pay.3. Backend
StreamCreated/Withdrawn/Cancelledevents through the existing event indexer (seeTODO.md— the poller andeventstable already exist).server/src/db/migrations/029_stipend_streams.sqlplus.undo.sqlmirroring stream state for fast queries.GET /api/streams?recipient=andGET /api/streams/:id.4. Frontend
src/hooks/useStipendStream.tsand a stream card on the scholar dashboard: a live-ticking withdrawable balance (interpolate between ledger closes client-side), total streamed, time remaining, and a Withdraw button.Acceptance criteria
total_amountand never over-payswithdrawn + refunded == total_amountinvariant testNotes for contributors
contracts/milestone_escrow/.total_amount.