Saffron · Managed Vault

One vault.
One protocol.
One curator.

A single-asset, fixed-term, single-protocol on-chain vault. Curator-operated, performance-fee only, scales horizontally by deployment — not by governance.
Solidity 0.8.28 · ERC-4626 share token · Foundry test suite · Two highest-value invariants fuzzed
Summary · TL;DR

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.

01 / IMMUTABLE TARGET
One protocol per vault

The connected protocol is baked in at deployment. Depositors know exactly what they are exposed to before depositing.

02 / SINGLE-SIDED
No Fixed / Variable split

One tranche. The curator multisig replaces the Fixed counterparty operationally.

03 / PERFORMANCE FEE ONLY
Zero yield ⇒ zero fee

No upfront premium. Fees are taken from realised yield at settlement, split between treasury and curator.

04 / BOUNDED TRUST
Curator can't escape

Curator authority is scoped to the connected protocol only. Arbitrary transfers revert on-chain.

05 / SCALE BY DEPLOYMENT
No on-chain whitelist

New yield sources spin up new vaults via the factory. No governance entries, no shared allocation manager.

06 / SETTLEMENT IS UNCONDITIONAL
Admin pause cannot block redemption

Admin may pause new deposits. Settlement at last-reported NAV must always be reachable.

Why a new model

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.

Money in motion · master diagram

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.

PHASE 1 · DEPOSIT WINDOW PHASE 2 · TERM (CURATOR) PHASE 3 · UNWIND PHASE 4 · SETTLED Depositor Vault Connected protocol Treasury Curator wallet: 100 USDC approves vault wallet: 100 smUSDC shares no exits, no new deposits wallet: 100 smUSDC waiting for settle() wallet: 104 USDC redeem() burns shares idle: + 100 USDC position: 0 mints 100 smUSDC principalAtStart = 100 idle: 0 USDC position: 105 (reported) totalAssets() = 105 curator publishes NAV marks idle: 105 USDC position: 0 unwound; final NAV end() callable idle: 104 USDC position: 0 finalTotalAssets locked redemption open no interaction (vault not yet started) holds: 100 USDC supplied accrues yield over time aUSDC / morphoShares / PT / LP curator may rebalance within the protocol or claim rewards holds: 0 curator withdrew 105 returned to vault no interaction no flow no flow no flow + 0.5 USDC no flow drives vault → protocol no flow + 0.5 USDC USDC → ← shares curatorCall: USDC → ← USDC + yield ← principal + net yield treasuryBps fee curatorBps fee
Numbers are illustrative (100 USDC deposit, 5 USDC realised yield, 10% / 10% fee split). Real values are vault-specific and partly TBD (see Open Questions).

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.
Money in · deposit phase

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.

DEPOSITOR USDC (ERC-20) MANAGED VAULT approve(vault, type(uint256).max) deposit(amount, receiver) transferFrom(depositor, vault, amount) USDC moves: depositor → vault _mint(receiver, shares) share math (ERC-4626): shares = amount × totalSupply ÷ totalAssets first depositor: 1:1 (after OZ virtual-shares offset)

Preconditions enforced on-chain

  • state == Initialized
  • block.timestamp < depositWindowEnd
  • depositsPaused == 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.

Inside the vault · during Started

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.

TOTAL ASSETS COMPOSITION OVER TIME USDC idle 100 position 0 totalAssets = 100 just started idle 0 position 100 totalAssets = 100 curator deployed idle 0 position 105 totalAssets = 105 yield, reportNav() idle 50 position 55 totalAssets = 105 partial unwind idle 105 position 0 totalAssets = 105 end() callable t = start t = term end

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.
Outside the vault · what it touches

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.

ManagedVault single-asset, single-protocol state machine + accounting Underlying ERC-20 e.g. USDC, DAI, WETH transferFrom / transfer balanceOf / approve connectedProtocol single external integration arbitrary calldata via curatorCall(target, data, ...) Curator multisig msg.sender for curatorCall, reportNav Treasury address passive receiver of fee paid via transfer() at settle() balance / approve USDC → ← position / USDC signs curatorCall fee transfer

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.
Trust & safety · §10.2 in detail

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.

CURATOR VAULT USDC (asset) target == connectedProtocol curatorCall(target, data, value, approveUnderlying=true) require(target == connectedProtocol) forceApprove(target, value) target.functionCall(data) transferFrom(vault, target, value) returns: position handle / shares / NFT id forceApprove(target, 0) returns bytes

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.

NAV reporting · §8

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.

Curator |Δ share value| ≤ guardrail? reportNav() succeeds new NAV is recorded immediately proposeNavMove() queue large move wait navMoveTimelock confirmNavMove() applies it reportNav / propose yes no

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.

Money out · settlement & redemption

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.

ANYONE VAULT USDC RECIPIENTS settle() · state == Ended finalAssets = USDC.balanceOf(vault) yield = max(0, finalAssets − principal) treasuryFee = yield × treasuryBps / 10_000 transfer(treasury, treasuryFee) transfer(curator, curatorFee) finalTotalAssets = balanceOf(vault); state = Settled redeem(shares, receiver, owner) assets = shares × finalTotalAssets ÷ totalSupply _burn(owner, shares); transfer(receiver, assets)

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, not balanceOf(vault), so a stray donation later doesn't skew payouts.
Fees · §7

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.

principalAtStart returned to depositors in full realisedYield subject to feeRatio split treasuryBps to Saffron treasury curatorBps to curator multisig Depositor net redemption = principalAtStart + (realisedYield − treasuryFee − curatorFee) finalTotalAssets / totalShares × balanceOf(depositor)

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.

For users · what's in your wallet

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.

YOUR POSITION TOKEN smUSDC · 100.000000 Saffron Managed USDC (Aave term, 30 days) balanceOf(you) = 100e6 shares decimals = 6 (matches USDC) transferable = true tradeable = wherever the share token is listed claim ≈ convertToAssets(100e6) = 104.5e6 USDC (updates live via curator NAV marks) WHAT IT REPRESENTS A pro-rata claim on the vault's assets. Your share = balanceOf(you) ÷ totalSupply Your claim = share × totalAssets() Claim becomes exact (locked) at Settled. finalTotalAssets is snapshot at settle(); redeem uses it. Transferring shares transfers the claim. e.g. sell on secondary market before settlement; whoever holds shares at redemption gets the assets.

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.

For users · what you can see on-chain

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).

MANAGED VAULT · SMUSDC · AAVE · 30D TERM state() Started 04 / 30 days elapsed termEnd() − now 26 days until end() callable connectedProtocol() 0x87870…fdf2 Aave V3 Pool · mainnet curator() 0xc0fe…cafe named multisig idleAssets() 0 USDC sitting in vault reportedPositionValue() 104.5 USDC curator-marked, in Aave totalAssets() 104.5 USDC share price baseline convertToAssets(yourShares) 10.45 USDC your current claim navMoveGuardrailBps() 500 bps (5%) navMoveTimelock() 1 day feeRatio() 1000 / 1000 bps depositsPaused() false
Mock UI built only from public view functions on the vault — no off-chain oracle, no indexer required for read.

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()
Trust model · roles

Who can do what

Four roles, each with narrow, legible authority. Crossing the line reverts.

RoleIdentityAuthority
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.

Lifecycle · §4

State machine

Four states, one-way transitions, enforced on-chain. Each state gates a different set of allowed actions.

STATE 01 STATE 02 STATE 03 STATE 04 Initialized Started Ended Settled deposit window deposits / refunds curator operates on connectedProtocol term elapsed position unwound fees taken redemption open start() window closes end() term + unwound settle() fees split

Transition rules

  • Initialized → Started: anyone may call start() once block.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.
Lifecycle · timeline view

What each actor does, in order

Same four states, viewed as an actor-vs-time grid.

DEPOSIT WINDOW TERM (CURATOR) UNWIND SETTLED Admin Curator Depositor Vault createVault() curatorCall(deploy) reportNav() reportNav() curatorCall(unwind) deposit() deposit() redeem() start() end() settle()

Allowed in deposit window

deposit withdraw no curator activity

Allowed in term

curatorCall reportNav no deposits / withdrawals

Allowed after settle

redeem curator inactive

Trust & safety · §10.2 visual

Curator authority is scoped

The curator can do everything on the connected protocol and nothing anywhere else. Calls to arbitrary targets revert with TargetNotConnectedProtocol.

Curator multisig msg.sender ManagedVault curatorCall(target, data, value) connectedProtocol supply, borrow, mint, swap, claim, … ALLOWED any other address curator EOA, router, drainer, … REVERTS target == connectedProtocol target != connectedProtocol

Implementation

  • connectedProtocol is immutable — set in the constructor, no setter.
  • curatorCall requires target == connectedProtocol; otherwise reverts with TargetNotConnectedProtocol(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.
Trust & safety · §10

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.

Scaling model · §11

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.

ManagedVaultFactory createVault(params) ManagedVault A curator α · USDC · 30 days ManagedVault B curator β · USDC · 90 days ManagedVault C curator γ · ETH · 14 days Aave v3 supply USDC Pendle PT / YT split Uniswap V3 range LP

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.

Scope · §12

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).

Open questions · §13

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.

OPEN QUESTION 1
Fee split percentages

Split of realised yield between Saffron treasury and curator multisig. Modelled as FeeRatio { treasuryBps, curatorBps } in VaultConfig.

OPEN QUESTION 2
NAV-move guardrail percentage

Threshold above which a NAV update needs the timelocked path. Stored as navMoveGuardrailBps.

OPEN QUESTION 3
Timelock duration for confirmed NAV moves

How long a queued out-of-guardrail NAV proposal sits before it can be confirmed. Stored as navMoveTimelock.

OPEN QUESTION 4
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.

OPEN QUESTION 5
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.

Implementation · §14

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 math
  • ManagedVaultFactory.sol — per-vault deployer, no whitelist
  • adapters/AdapterBase.sol — optional thin adapter
  • interfaces/, libraries/

test/

  • unit/ — FSM, deposit window, NAV guardrail, fee math, curator scoping
  • invariant/ — curator-cannot-escape, settlement-always-reachable
  • mocks/, 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.