Withdraw Flow

Three async phases: requestRedeem → fulfillRedeem → redeem / withdraw

Starting state (from allocate flow):  800 USDC deployed to strategy · 200 USDC idle in FundVault · yield has accrued
FundNavFeed = 800 |  balanceOf(FV) = 200 |  totalSupply = 1,000 shares |  PPS = 1.00
① requestRedeem — Lock shares, queue the request
OWNER / CONTROLLER User holds 200 shares ASSET VAULT AssetVault shares locked here VAULT STATE DELTA pendingAssets +200 redeemShares +200 PPS → unchanged ✓ requestRedeem(200 shares, controller, owner) shares locked state update No USDC movement — shares sit locked in AssetVault
Step Action Effect
Owner calls AssetVault.requestRedeem(shares, controller, owner) Entry point — validates NAV freshness, checks no pending request
ShareToken.safeTransferFrom(owner, assetVault, shares) Shares locked in AssetVault — owner cannot trade them
AsyncRequestManager.requestRedeem(shares, controller, owner) Request stored: { shares, assets, isFulfilled: false }
assets = convertToAssets(asset, shares) Asset amount locked at current PPS — not re-priced later
VaultState: pendingAssets += assets, redeemShares += shares effNAV ↓ and effectiveSupply ↓ proportionally → PPS unchanged

State Snapshot

Starting: 1,000 USDC total NAV (800 strategy + 200 idle), totalSupply = 1,000 shares, PPS = 1.00. User redeems 200 shares → locks in 200 USDC.

Event pendingAssets redeemShares effNAV effectiveSupply PPS
Before requestRedeem 001,0001,000 1.00
requestRedeem(200 shares)
assets = 200 × 1.00 = 200
200 200 800 800 1.00
Why PPS is unchanged: effNAV = totalNAV − pendingAssets − claimableAssets and effectiveSupply = totalSupply − redeemShares. Both shrink by the same ratio (200 shares worth 200 USDC at PPS 1.00), so the quotient stays constant. The asset amount is locked in at the current PPS — no re-pricing occurs when the operator fulfills.
② fulfillRedeem — Pull USDC from FundVault, mark claimable
⚠ Pre-requisite — Ensure FundVault has enough idle balance
fulfillRedeem calls FundVault.releaseAssets(amount), which requires idleAssets(asset) ≥ amount. If the FundVault does not have enough idle USDC, the operator (or curator) must first deallocate assets from the strategy back to FundVault:

Step 0: Curator calls FundVault.deallocate(strategy, amount) → strategy transfers USDC back to FundVault
Then: Operator calls AssetVault.fulfillRedeem(totalAmount, controllers)

In this example, FundVault only has 200 USDC idle but needs 200 for fulfillment. If idle balance were insufficient (e.g. only 50 USDC), the curator must deallocate at least 150 from the strategy first.
STRATEGY (IF NEEDED) deallocate → FundVault ROLE: OPERATOR AssetVault.fulfillRedeem() SHARED · FUND VAULT FundVault balanceOf: 200 → 0 ↓ ASSET VAULT AssetVault isFulfilled = true ✓ step 0 (if needed) USDC transferred FundVault.releaseAssets(200) → AssetVault pendingAssets − 200 → claimableAssets + 200 effNAV and PPS stay constant ✓ releaseAssets() reverts with InsufficientIdle if balanceOf(FV) < totalAmount Curator must deallocate from strategy first to top up idle balance
Step Action Effect
0 (If needed) Curator calls FundVault.deallocate(strategy, amount) Strategy sends USDC back to FundVault — idle balance ↑
Operator calls AssetVault.fulfillRedeem(200, [controller]) Restricted to OPERATOR_ROLE
FundVault.releaseAssets(200) called internally balanceOf(fundVault) − 200
USDC arrives in AssetVault balanceOf(assetVault) + 200
AsyncRequestManager.fulfillRedeem([controller]) request.isFulfilled = true — controller may now claim
VaultState: pendingAssets −= 200, claimableAssets += 200 effNAV formula: both cancel out → PPS unchanged

State Snapshot

After requestRedeem: 200 USDC pending, 200 shares locked. Operator now calls fulfillRedeem(200).

Event balanceOf(FV) balanceOf(AV) pendingAssets claimableAssets PPS
After requestRedeem
pre-fulfill state
20002000 1.00
fulfillRedeem(200 USDC) 0 200 0 200 1.00
Why PPS is unchanged: When FundVault sends 200 USDC to AssetVault, balanceOf(FV) falls by 200 but claimableAssets rises by 200 — the NAV formula totalNAV = FundNavFeed + balanceOf(FV) + claimableAssets nets to zero change. Simultaneously pendingAssets drops by 200, so effNAV (which deducts both) is also unchanged. The controller's shares are still locked; effectiveSupply does not move yet.
③ withdraw / redeem — Burn shares, release USDC
CONTROLLER User calls withdraw() ASSET VAULT AssetVault holds 200 USDC + locked shares RECEIVER User Wallet receives 200 USDC ↑ SHARE TOKEN burn(assetVault, 200 shares) withdraw(200 USDC, receiver, controller) or redeem(200 shares, ...) 200 USDC burn shares
Step Action Effect
Controller calls AssetVault.withdraw(assets, receiver, controller) or .redeem(shares, receiver, controller) Validates controller, request must be isFulfilled = true
AsyncRequestManager.withdraw(controller, assets) computes share amount
(or .redeem(controller, shares) computes asset amount)
VaultState: claimableAssets −= 200, redeemShares −= 200
ShareToken.burn(assetVault, 200) totalSupply 1,000 → 800 — redeemShares also drops 200 → 0
IERC20(asset).safeTransfer(receiver, 200) 200 USDC arrives in receiver wallet

State Snapshot

After fulfillRedeem: 200 USDC claimable in AssetVault, 200 shares locked. Controller now claims.

Event claimableAssets redeemShares totalSupply effectiveSupply PPS
After fulfillRedeem
pre-claim state
2002001,000800 1.00
withdraw(200 USDC)
burns 200 shares
0 0 800 800 1.00

Full Flow — PPS Through All Phases

Summary across all three phases: PPS = 1.00 throughout.

Phase balanceOf(FV) balanceOf(AV) pendingAssets claimableAssets effNAV effectiveSupply PPS
Initial state 200000 1,0001,0001.00
After requestRedeem(200 shares) 2000 2000 8008001.00
After fulfillRedeem(200) 0200 0200 8008001.00
After withdraw(200 USDC) 00 00 8008001.00
Final state: 200 shares burned · 200 USDC delivered · 800 shares remain · effNAV = 800 USDC. PPS = 800 / 800 = 1.00 — identical to the starting PPS. The withdraw process is economically neutral for remaining holders: they neither gain nor lose from another user exiting.
Operational Notes
1. Deallocate before fulfillRedeem if idle balance is insufficient
FundVault.releaseAssets() checks idleAssets(asset) ≥ amount and reverts with InsufficientIdle if not met. When most USDC is deployed in strategies, the operator must coordinate with the curator to call FundVault.deallocate(strategy, amount) first, pulling assets back from the strategy to FundVault. Only then can fulfillRedeem succeed.
2. FundNavFeed should be synced after deallocate
After deallocate, the strategy holds fewer assets but FundNavFeed (an off-chain oracle value) still reflects the old amount. Update FundNavFeed to match the new strategy.totalAssets() before or atomically with the next updateNav call — otherwise PPS will be overstated, since idle USDC (counted via balanceOf(FV)) increased while the feed hasn't decreased yet.
3. Partial claims are supported
Users can call withdraw(assets) or redeem(shares) for less than their full fulfilled amount. The remaining balance stays in the request and can be claimed later. When the full amount is claimed, the request is cleaned up automatically.
4. One active request per controller per vault
A controller cannot submit a new requestRedeem while they have a pending (unfulfilled) request for the same asset vault. The previous request must be fulfilled and fully claimed first.
5. withdraw() vs redeem() — two ways to claim
Both functions do the same thing but accept different input:
  • withdraw(assets, receiver, controller) — specify the USDC amount, contract computes shares to burn
  • redeem(shares, receiver, controller) — specify the share amount, contract computes USDC to send
Both require isFulfilled = true. Both emit the same Withdraw event.
6. Asset amount is locked at request-time PPS
When requestRedeem is called, the asset amount is computed using the current PPS and stored in the request. This amount does not change when the operator fulfills — even if PPS has moved since the request. This protects both the user (guaranteed exit price) and the protocol (no PPS manipulation between request and fulfillment).