One vault.
One protocol.
One curator.
The vault in one screen
Depositors put one asset in during a window, a named curator multisig deploys that capital on one external protocol for a fixed term, and at the end everyone redeems principal plus their share of net yield. New yield sources scale by deploying new vaults.
One protocol per vault
The connected protocol is baked in at deployment. Depositors know exactly what they are exposed to before depositing.
No Fixed / Variable split
One tranche. The curator multisig replaces the Fixed counterparty operationally.
Zero yield ⇒ zero fee
No upfront premium. Fees are taken from realised yield at settlement, split between treasury and curator.
Curator can't escape
Curator authority is scoped to the connected protocol only. Arbitrary transfers revert on-chain.
No on-chain whitelist
New yield sources spin up new vaults via the factory. No governance entries, no shared allocation manager.
Admin pause cannot block redemption
Admin may pause new deposits. Settlement at last-reported NAV must always be reachable.
From FIV to Managed Vault
FIV required a custom adapter and a separate audit per yield source. Managed Vault scales horizontally instead — same vault contract, parameter-only.
Fixed Income Vault (legacy)
- Fixed / Variable tranche split
- Custom adapter per yield source
- Separate audit per integration
- Allocation managed via shared contract / whitelist
- Adding a yield source = code change + governance
Managed Vault
- Single-sided; curator multisig instead of Fixed side
- Generic external-call path locked to connectedProtocol
- One vault, one protocol, parameter-only
- No on-chain whitelist; no shared allocation manager
- Adding a yield source = deploy a new vault
Bound the trust, don't remove it
Defences against loss are off-chain and reputational: the admin's choice of protocol, the curator's mandate, and the on-chain trust & safety invariants. Every depositor in a vault sits at the same level of the stack — there is no protected tranche.
Asset flow across the lifecycle
Where the underlying asset (e.g. USDC) sits at each phase, and how it moves between depositor, vault, connected protocol, treasury and curator.
Reading this diagram
- Every column is one lifecycle phase. Boxes show what is held where, arrows show what moves.
- USDC only ever lives in three places: depositor wallets, the vault, or the connected protocol. There is no fourth bucket the curator can route to.
- Fees are taken only at the Phase 4 boundary, and only out of the yield above
principalAtStart.
What happens when a user deposits
Standard ERC-4626 deposit, gated by the deposit window and the admin pause flag. The vault
pulls the underlying via transferFrom and mints proportional shares.
Preconditions enforced on-chain
state == Initializedblock.timestamp < depositWindowEnddepositsPaused == false
If any fail, maxDeposit(receiver) returns 0 and the deposit reverts with the standard ERC-4626 error.
What the depositor actually does
// once per token USDC.approve(vault, type(uint256).max); // during the window vault.deposit(100e6, msg.sender); // shares now in wallet vault.balanceOf(msg.sender); // → 100e6 smUSDC
Withdrawal during the window
Depositors may rescind before the window closes by calling redeem /
withdraw against their shares while state == Initialized. After
start(), mid-term exits are disabled in the scaffold pending Open Question 5.
What the vault actually holds, mid-term
At any instant during Started, the vault's accounting splits into two buckets:
idle underlying that hasn't left the contract yet, and the curator-reported value of the
open position on the connected protocol.
idleAssets()
The contract's raw USDC.balanceOf(vault). Sits inside the vault, never lent to anyone.
reportedPositionValue()
Last curator-published mark for the on-protocol position. Independent of idle balance. Bounded by the NAV guardrail.
totalAssets()
The sum. This is what ERC-4626 uses to price shares. Settles to the locked finalTotalAssets at Settled.
Why the split matters
- The two buckets are independently observable — the idle portion is trustless (real balance), the position portion is curator-marked (until unwind).
- Anyone can compute the live share price; the guardrail ensures the marked portion can't lurch suspiciously without a timelocked confirmation.
- To exit
Started, the curator must drive the position bucket back to zero, i.e. move all value back to the idle bucket.
External integrations
From the vault's point of view, the world is exactly three contracts: the underlying ERC-20,
the connected protocol, and the curator multisig that signs curatorCall.
Concrete examples of connectedProtocol
Aave V3 — supply USDC
connectedProtocol = Aave Pool
vault.curatorCall( aavePool, abi.encodeWithSelector( Pool.supply.selector, USDC, amount, vault, 0 ), amount, true // approveUnderlying );
Vault ends up holding aUSDC. NAV grows as aUSDC accrues.
Morpho Blue — supply
connectedProtocol = Morpho Blue
vault.curatorCall( morphoBlue, abi.encodeWithSelector( IMorpho.supply.selector, marketParams, amount, 0, vault, "" ), amount, true );
Vault holds Morpho supply shares for that market.
Pendle — mint PT
connectedProtocol = Pendle Router
vault.curatorCall( pendleRouter, abi.encodeWithSelector( IPRouter.swapExactTokenForPt.selector, vault, market, minPtOut, ... ), amount, true );
Vault holds PT tokens; redeems at maturity for principal.
Uniswap V3 — range LP
connectedProtocol = NonfungiblePositionManager
vault.curatorCall( uniNPM, abi.encodeWithSelector( INPM.mint.selector, mintParams ), amount, true );
Vault holds the LP NFT; collects fees via further curatorCalls.
What the vault deliberately doesn't touch
- Any address other than
connectedProtocol, the underlying ERC-20 itself, the treasury (at settlement), and the curator multisig (at settlement). - No DEX routers, bridges, swap aggregators, or yield optimisers unless one of those is the connected protocol for the vault.
The curator's single door
curatorCall is the only escape hatch from the vault. It is locked to one
destination and wraps the call with scoped approvals so the target can never re-pull funds
after the call returns.
Why the reset-to-zero matters
The approval is set only for the duration of one call and reset before
curatorCall returns. A malicious or buggy target cannot stash an allowance
and re-pull the vault's balance afterwards. Each call uses exactly the value passed
in.
What still has to be trusted
The connected protocol itself. If that contract behaves maliciously, capital sitting inside it can be lost. The vault scopes blast radius to a single protocol; it does not magically make the protocol safe.
Fuzzed as an invariant
Across 1,376+ random non-connected targets in the test suite, every attempt reverts with
TargetNotConnectedProtocol and the vault's accounting stays whole:
idle + protocolDeposits == principalAtStart.
Bounded NAV moves
A single NAV update cannot move share value by more than navMoveGuardrailBps
without a separate timelocked confirmation. Guards against fat-fingered or malicious marks.
Live NAV (during Started)
totalAssets() = idleAssets() + reportedPositionValue(). Idle is real; the position part is curator-marked.
Final NAV (at Ended)
Locked when the position is unwound. Redemptions execute against this locked NAV; live marks no longer matter.
Why a timelock instead of a hard cap
Some honest events are large (a depeg fix-up, a vault rebase). A hard cap would make them unreportable. A timelock makes them reportable but legible — the world sees the queued move before it executes, with enough lead time for depositors / admin to react.
Two transactions to pay everyone
After the curator has unwound, two things happen: settle() takes the
performance fee and locks final NAV, then each depositor calls redeem /
withdraw against the locked NAV.
Settlement properties
- Anyone can call
settle()— not just admin or curator. - Admin pause does not block this transition (fuzzed invariant).
- If
finalAssets ≤ principalAtStart, the yield is zero and no fee is taken.
Redemption properties
- Available indefinitely after
Settled— no expiry on the right to redeem. - Shares are standard ERC-20: transferable, so the redeemer doesn't have to be the original depositor.
- Math uses the locked
finalTotalAssets, notbalanceOf(vault), so a stray donation later doesn't skew payouts.
Performance fee only, taken at settlement
No upfront premium. Fee is feeRatio × realisedYield, computed and taken when
the vault moves to Settled and split between Saffron treasury and the curator.
Boundary cases
Zero yield
finalAssets ≤ principal ⇒ fee = 0. Curator gets nothing.
Loss
NAV is below principal ⇒ fee = 0. Loss is shared pro-rata across depositors.
Positive yield
fee = yield × (treasuryBps + curatorBps), paid before any depositor redeems.
What the user gets — the share token
On deposit() the depositor receives a standard ERC-4626 share token. It's a
regular ERC-20: transferable, viewable in every wallet, and tradeable on secondary venues if
any exist for this vault.
Deposit time
USDC.approve(vault, amount); shares = vault.deposit(amount, you); // shares represents your stake in the vault
Redemption time (after Settled)
assets = vault.redeem(shares, you, you); // you receive principal + your share of net yield
The position-token decision is still open
Open Question 4 in the spec — ERC-4626 share token (the current scaffold) vs ERC-721 per-deposit NFT. The fungible share token gives users a tradeable, divisible position; an NFT would record each deposit individually. Product decision pending.
Position dashboard — view functions
The vault exposes the full position state via cheap view functions. Any UI, indexer, or third party can reconstruct the on-protocol position off-chain from these reads (spec §10.4).
The full read surface
// position + accounting vault.idleAssets() // USDC sitting inside the vault vault.reportedPositionValue() // curator-reported value of the open position vault.totalAssets() // idle + reported (or finalTotalAssets in Settled) vault.convertToAssets(shares) // per-user claim vault.previewRedeem(shares) // what you'd get if you redeemed right now // lifecycle vault.state(), vault.depositWindowEnd(), vault.termEnd(), vault.finalTotalAssets() // immutable config (read once before depositing) vault.connectedProtocol(), vault.curator(), vault.admin(), vault.treasury() vault.feeRatio(), vault.navMoveGuardrailBps(), vault.navMoveTimelock()
Who can do what
Four roles, each with narrow, legible authority. Crossing the line reverts.
| Role | Identity | Authority |
|---|---|---|
| Admin | Saffron core multisig |
Deploy & configure vaults. Emergency-pause new deposits.
cannot block settlement at last-reported NAV. |
| Curator | Per-vault named multisig |
Operate vault capital on connectedProtocol. Publish NAV updates.
Unwind before term end.
cannot transfer to arbitrary addresses. |
| Depositor | Any holder of the underlying asset | Deposit during the window. Hold position token. Redeem at settlement. |
| Treasury | Saffron-controlled address | Receive Saffron's share of yield fee at settlement. |
Two-multisig separation of duties
Admin and curator are different multisigs. The admin can not operate vault capital, and the curator can not reconfigure the vault. There is no single role that can both deploy a vault and drain it.
State machine
Four states, one-way transitions, enforced on-chain. Each state gates a different set of allowed actions.
Transition rules
- Initialized → Started: anyone may call
start()onceblock.timestamp ≥ depositWindowEnd. Principal at that moment is snapshotted as the baseline for yield. - Started → Ended: anyone may call
end()once the term has elapsed and the curator has fully unwound the reported position to zero. - Ended → Settled: anyone may call
settle(). Computes performance fee on realised yield, transfers fee shares, locks final NAV.
What each actor does, in order
Same four states, viewed as an actor-vs-time grid.
Allowed in deposit window
deposit withdraw no curator activity
Allowed in term
curatorCall reportNav no deposits / withdrawals
Allowed after settle
redeem curator inactive
Curator authority is scoped
The curator can do everything on the connected protocol and nothing
anywhere else. Calls to arbitrary targets revert with
TargetNotConnectedProtocol.
Implementation
- connectedProtocol is immutable — set in the constructor, no setter.
curatorCallrequirestarget == connectedProtocol; otherwise reverts withTargetNotConnectedProtocol(target).- Approvals to the target are scoped to the single call:
forceApprove(target, value)before,forceApprove(target, 0)after. - NAV updates also enforce
newTotal ≥ idleAssets()to prevent quietly under-reporting an escape.
On-chain invariants
Five invariants pinned in the implementation. The two starred are the highest-value fuzz targets per spec §14 and are exercised in the invariant test suite.
1. Protocol immutability
connectedProtocol is set in the constructor and has no setter. The vault can't be re-pointed.
2. Curator authority is scoped
Every curator call must target connectedProtocol. Arbitrary targets revert. Fuzzed across 1,376+ random targets in the invariant suite.
3. Bounded NAV moves
Any NAV update larger than navMoveGuardrailBps requires proposeNavMove → wait → confirmNavMove.
4. Public position dashboard
idleAssets(), reportedPositionValue(), totalAssets() are public view functions. UI reconstructs the position from these.
5. Emergency pause is scoped
Admin can pause new deposits but cannot block settle() or redeem(). Fuzzed across 4,096 admin pause toggles in the invariant suite.
Add yield sources by deployment, not by governance
There is no on-chain whitelist of allowed protocols. To onboard a new yield source the admin deploys a new vault with that protocol baked in. Existing vaults are unaffected.
What this lets you do
- Run multiple vaults in parallel against different protocols, terms, and curators.
- Sunset a yield source by simply not deploying new vaults for it.
- Audit risk per-deployment, not per-governance-vote.
Genuinely exotic protocols
The common case is parameter-only — the curator drives the protocol via the generic
curatorCall. Truly exotic protocols can still get a thin adapter contract
(IProtocolAdapter); the vault calls the adapter, the adapter calls the
protocol.
Deliberately out of scope
Discipline list. These should not be built unless the spec changes.
Fixed / Variable tranche split
The product is single-sided. The curator role replaces the Fixed counterparty operationally.
Multi-protocol routing within a single vault
One protocol per vault, immutable.
On-chain protocol whitelist or shared AllocationManager
Yield sources scale by deployment, not governance entries.
Per-deposit fees or upfront premiums
Fees are performance-only and taken at settlement.
Insurance / protection tiers within a vault
Every depositor sits at the same level of the stack. Loss is shared pro-rata.
Cross-vault rebalancing or aggregation
Vaults are independent. Rebalancing is off-chain (deploy a different vault).
What product still has to decide
Each item below is a constructor parameter or configurable TODO — not hardcoded into immutable defaults. Locking these is a product decision, not a code change.
Fee split percentages
Split of realised yield between Saffron treasury and curator multisig. Modelled as FeeRatio { treasuryBps, curatorBps } in VaultConfig.
NAV-move guardrail percentage
Threshold above which a NAV update needs the timelocked path. Stored as navMoveGuardrailBps.
Timelock duration for confirmed NAV moves
How long a queued out-of-guardrail NAV proposal sits before it can be confirmed. Stored as navMoveTimelock.
Position-token standard
ERC-4626 share token (current scaffold, fungible, tooling-rich) vs ERC-721 per-deposit NFT (more bespoke; better for variable terms). Affects which vault implementation gets deployed.
Early-exit mechanics
Whether depositors can exit before term end, and what (if any) curator-triggered early-unwind condition exists. Current scaffold disallows mid-term exits via maxWithdraw / maxRedeem.
How the code is laid out
Foundry, OpenZeppelin v5, Solidity 0.8.28. Per-vault factory deploys ERC-4626 instances with the spec encoded as immutables.
src/
ManagedVault.sol— ERC-4626, FSM, curator scoping, NAV guardrail, fee mathManagedVaultFactory.sol— per-vault deployer, no whitelistadapters/AdapterBase.sol— optional thin adapterinterfaces/,libraries/
test/
unit/— FSM, deposit window, NAV guardrail, fee math, curator scopinginvariant/— curator-cannot-escape, settlement-always-reachablemocks/,utils/Fixtures.sol
Run it
forge install forge fmt --check forge build --sizes forge test -vvv FOUNDRY_PROFILE=ci forge test -vvv # 1024 fuzz runs, depth 128 invariants FOUNDRY_PROFILE=coverage forge coverage # optimizer off, source maps usable slither . --config-file slither.config.json
What this deck is missing
- Concrete fee / guardrail / timelock numbers (Open Questions 1–3).
- The position-token decision (Open Question 4).
- Early-exit policy (Open Question 5).
Everything else in the spec is encoded in the code, the tests, or this deck.