Summary
Build a treasury yield strategy — a governed way for idle scholarship funds to earn a return between disbursements, behind a pluggable strategy adapter, a governance-set allocation cap, and a liquidity buffer that guarantees approved scholarships are never blocked by an investment. Today scholarship_treasury can only deposit, disburse, and report get_balance (contracts/scholarship_treasury/src/lib.rs:410, :506, :690) — every donated USDC sits completely idle from the moment it arrives until the moment it is paid out.
Background
Donations arrive in lumps and go out in tranches over months. The gap between those two facts is dead capital, and for a scholarship treasury that capital is measured in learners not funded.
The DAO already has the governance machinery to make this decision safely — proposals, quorum, approval thresholds, a timelock vault, and a pause switch are all in the treasury contract already. What is missing is anything for that machinery to govern beyond disbursement. This issue adds the smallest responsible version of that: an adapter interface, hard caps, and an exit.
This one deserves argument before code. A scholarship treasury taking on protocol risk is a real policy decision, not a technical nicety. If you think the answer is that LearnVault should hold only cash, say so in this issue — a well-argued "no" closes it, and that is a good outcome.
Scope
Smart contract, backend, and frontend. The contract is the substance; the API and dashboard exist so the DAO can see what it has done. Integrating a specific lending protocol is deliberately behind an adapter — ship the interface plus one adapter, and a mock adapter for tests.
What to build
1. Strategy adapter interface
- A trait/interface contract defining
deposit, withdraw, balance_of, and max_withdrawable, so the treasury is never hardwired to one venue and a bad venue can be swapped without a treasury upgrade.
- One real adapter against an established Soroban lending market, plus a mock adapter used by the entire test suite. The mock must be able to simulate partial withdrawal, withdrawal failure, and loss — a strategy layer that has only ever been tested against a cooperative counterparty is untested.
2. Treasury allocation controls (contracts/scholarship_treasury/)
allocate(strategy, amount) and deallocate(strategy, amount), callable only through an executed governance proposal, subject to the existing timelock. No admin shortcut.
max_allocation_bps — a governance-set ceiling on the share of treasury value that may ever be at risk. Enforced on every allocation, in basis points, defaulting conservatively.
- A liquidity buffer floor: allocation must leave enough idle balance to cover all approved-but-unpaid proposals plus a configured margin. A scholarship that cannot be paid because the money is earning yield is a total failure of this feature, and it must be structurally impossible rather than a matter of care.
emergency_withdraw(strategy) — pulls everything recoverable, callable by the existing pause authority without waiting for the timelock. Losing yield is acceptable; being unable to exit is not.
- Every allocation, deallocation, harvest, and emergency exit emits an event.
3. Accounting
- Track principal allocated and yield accrued separately.
get_balance must keep meaning what callers already assume it means; add explicit accessors for idle, allocated, and total value rather than quietly redefining the existing one.
- Handle the case where a strategy returns less than was put in. Loss must be representable in the accounting, not an arithmetic panic — the contract's behaviour when the number goes down is the whole safety story.
- Yield accrues to the treasury and is disbursable like any other balance.
4. Backend and frontend
- Index the new events through the existing indexer (
server/src/services/event-indexer.service.ts, server/src/lib/event-config.ts) into a migration-backed table, and expose allocation history and current position over the API.
- Extend the treasury dashboard (
src/pages/Treasury.tsx, src/components/treasury/) with idle vs. allocated split, yield earned to date, current strategy and cap, and the liquidity buffer status.
- Surface the risk honestly to donors — which protocol holds the funds, how much is exposed, and what the DAO voted to allow. A donor funding scholarships is entitled to know their donation is in a lending market.
5. Tests
- Allocation beyond
max_allocation_bps is rejected.
- Allocation that would breach the liquidity buffer is rejected, including when a proposal is approved after the allocation.
disburse succeeds for any approved proposal at any allocation level — assert this against a fully-capped treasury.
- Allocation without an executed proposal, or before the timelock elapses, is rejected.
- Emergency withdraw works while paused, and works when the strategy can only return part of the balance.
- A strategy reporting a loss is accounted for without panicking, and reduces total value correctly.
- Backend: every event maps to the right row; the dashboard reflects a mocked position.
Acceptance criteria
Notes for contributors
- Start in the issue thread, not the editor. Propose the default cap, the buffer margin, and the venue, and get maintainer agreement before writing the contract. These are policy, and a technically perfect PR with parameters nobody agreed to will not merge.
- Do not add a "just this once" admin allocation path. The moment one exists, it is the only path anyone uses, and the governance in this design becomes decoration.
- Integer maths on money: state your rounding direction and make it always favour the treasury. Write the test that proves repeated allocate/deallocate cycles cannot bleed value.
- Read
contracts/upgrade_timelock_vault/ and the existing pause implementation before designing the authorisation paths — reuse them rather than inventing a parallel mechanism.
Summary
Build a treasury yield strategy — a governed way for idle scholarship funds to earn a return between disbursements, behind a pluggable strategy adapter, a governance-set allocation cap, and a liquidity buffer that guarantees approved scholarships are never blocked by an investment. Today
scholarship_treasurycan onlydeposit,disburse, and reportget_balance(contracts/scholarship_treasury/src/lib.rs:410,:506,:690) — every donated USDC sits completely idle from the moment it arrives until the moment it is paid out.Background
Donations arrive in lumps and go out in tranches over months. The gap between those two facts is dead capital, and for a scholarship treasury that capital is measured in learners not funded.
The DAO already has the governance machinery to make this decision safely — proposals, quorum, approval thresholds, a timelock vault, and a pause switch are all in the treasury contract already. What is missing is anything for that machinery to govern beyond disbursement. This issue adds the smallest responsible version of that: an adapter interface, hard caps, and an exit.
This one deserves argument before code. A scholarship treasury taking on protocol risk is a real policy decision, not a technical nicety. If you think the answer is that LearnVault should hold only cash, say so in this issue — a well-argued "no" closes it, and that is a good outcome.
Scope
Smart contract, backend, and frontend. The contract is the substance; the API and dashboard exist so the DAO can see what it has done. Integrating a specific lending protocol is deliberately behind an adapter — ship the interface plus one adapter, and a mock adapter for tests.
What to build
1. Strategy adapter interface
deposit,withdraw,balance_of, andmax_withdrawable, so the treasury is never hardwired to one venue and a bad venue can be swapped without a treasury upgrade.2. Treasury allocation controls (
contracts/scholarship_treasury/)allocate(strategy, amount)anddeallocate(strategy, amount), callable only through an executed governance proposal, subject to the existing timelock. No admin shortcut.max_allocation_bps— a governance-set ceiling on the share of treasury value that may ever be at risk. Enforced on every allocation, in basis points, defaulting conservatively.emergency_withdraw(strategy)— pulls everything recoverable, callable by the existing pause authority without waiting for the timelock. Losing yield is acceptable; being unable to exit is not.3. Accounting
get_balancemust keep meaning what callers already assume it means; add explicit accessors for idle, allocated, and total value rather than quietly redefining the existing one.4. Backend and frontend
server/src/services/event-indexer.service.ts,server/src/lib/event-config.ts) into a migration-backed table, and expose allocation history and current position over the API.src/pages/Treasury.tsx,src/components/treasury/) with idle vs. allocated split, yield earned to date, current strategy and cap, and the liquidity buffer status.5. Tests
max_allocation_bpsis rejected.disbursesucceeds for any approved proposal at any allocation level — assert this against a fully-capped treasury.Acceptance criteria
cargo test --workspacepasses, including loss and partial-withdrawal casesNotes for contributors
contracts/upgrade_timelock_vault/and the existing pause implementation before designing the authorisation paths — reuse them rather than inventing a parallel mechanism.