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
| 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 | 0 | 0 | 1,000 | 1,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
Step 0: Curator calls
Then: Operator calls
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.
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 FundVaultThen: 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.
| 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 |
200 | 0 | 200 | 0 | 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
| 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 |
200 | 200 | 1,000 | 800 | 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 | 200 | 0 | 0 | 0 | 1,000 | 1,000 | 1.00 |
| After requestRedeem(200 shares) | 200 | 0 | 200 | 0 | 800 | 800 | 1.00 |
| After fulfillRedeem(200) | 0 | 200 | 0 | 200 | 800 | 800 | 1.00 |
| After withdraw(200 USDC) | 0 | 0 | 0 | 0 | 800 | 800 | 1.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
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
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
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:
Both functions do the same thing but accept different input:
withdraw(assets, receiver, controller)— specify the USDC amount, contract computes shares to burnredeem(shares, receiver, controller)— specify the share amount, contract computes USDC to send
isFulfilled = true. Both emit the same Withdraw event.
6. Asset amount is locked at request-time PPS
When
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).