Withdrawal Flow¶
Withdrawals are asynchronous — two separate transactions are required: a request from the investor and a processing call from the keeper.
Phase 1: Request¶
Trigger: Investor wishes to redeem shares for USDC. Caller: Any address — no pause check.
- Investor calls
requestWithdraw(shares, receiver). - Vault transfers
sharesfrommsg.senderinto escrow (held by vault from this point). - Request stored in FIFO queue:
nextRequestIdincrements.WithdrawRequested(requestId, owner, receiver, shares, timestamp)event emitted.- Returns
requestId— the investor uses this to track or cancel the request.
Shares are locked at request time
Shares are transferred to the vault at the moment requestWithdraw is called. Once escrowed, they cannot be transferred, used as collateral, or queued again until the request is processed or cancelled.
Phase 1b: Cancellation¶
At any point before processing, the original requester can cancel:
Caller: queue[requestId].owner only.
require(msg.sender == queue[requestId].owner)require(queue[requestId].shares > 0)— revert if already cancelled or processed.- Vault returns escrowed shares to
request.owner. queue[requestId].shares = 0— tombstone. The entry is NOT deleted.WithdrawCancelled(requestId, owner, sharesReturned)emitted.
Tombstone, not deletion
Setting shares = 0 marks the slot as cancelled without deleting the struct. When processWithdrawals reaches this slot, it detects shares == 0 and skips it, incrementing nextProcessId. This prevents the slot from blocking the queue while ensuring requestIds are never reused.
Shares returned, not USDC
Cancellation returns the original shares. The USDC value of those shares may differ from the submission time, as the share price reflects ongoing NAV changes.
Phase 2: Processing¶
Trigger: Keeper is ready to process the queue.
Caller: Permissioned keeper only — require(msg.sender == keeper).
The keeper calls processWithdrawals(maxCount).
Preamble — executed once at the start of the call¶
Day roll: If block.timestamp >= dayStart + 86400, set dayStart = block.timestamp, redeemedToday = 0, emit DayRolled(newDayStart, previousRedeemed). The day is a fixed 86400-second window from dayStart — not a calendar day. Roll happens before any request is processed.
Pause check: require(!paused()) — revert the entire call if paused.
Per-request loop (up to maxCount live requests)¶
Cancelled slot skip: If queue[nextProcessId].shares == 0 (tombstone), increment nextProcessId and continue to the next slot. Does not count toward maxCount. Does not revert.
For each live request:
-
Compute request value:
-
Daily cap check: If
redeemedToday + requestValue > dailyCap: stop. Do not process this or any subsequent request in this call. The request stays at the front of the queue for the next keeper call. -
Compute fill values (1e18 fixed-point — mandatory):
!!! danger "1e18 scaling is mandatory" Do NOT writefillBefore = redeemedToday * 1e18 / dailyCap fillAfter = (redeemedToday + requestValue) * 1e18 / dailyCapredeemedToday / dailyCap. Both values are USDC (6 dec) — integer division produces0for all partial fills, silently collapsing the curve toaggMarketNAVon every withdrawal. -
Compute curve-weighted aggregate vault NAV:
curveNAVis a total vault valuation (USDC, 6 dec) betweenaggMarketNAVandaggModeledNAV. It is not a per-share price. -
Compute exit value:
!!! danger "Divisor must be totalShares, not 1e18"curveNAVis a total vault valuation, not a per-share price. Writingrequest.shares * curveNAV / 1e18produces a nonsensically small result. -
Compute fee (ceiling division):
-
Reserve check:
!!! danger "Failure reverts the entire transaction" If thisrequirefails, all state changes from this call are rolled back. The queue is unchanged. The keeper must retry after the reserve is restored. Do NOT usetry/catchorif/elseto skip this request and continue — partial batch processing leaves accounting inconsistent. In normal operation thisrequireshould never trigger: step (2) ensures each request fits within the daily cap (2% of TVL) and the reserve target (15% of TVL) is always larger. A trigger indicates a serious system condition. -
Update accounting:
-
Pay out:
- Transfer
payoutUSDC torequest.receiver -
Transfer
feeUSDC tohouseBuffer -
Finalise:
- Burn
request.sharesfrom vault escrow delete queue[nextProcessId]- Increment
nextProcessId - Emit
WithdrawProcessed(requestId, receiver, payout, fee, curveNAV)
- Burn
Post-loop reserve check¶
After all requests: if idleReserve < aggMarketNAV * reserveTargetBps / 10000 / 2, call houseBuffer.topUpReserve() and emit ReserveTopupRequested(amount).
Daily Cap Behavior¶
If a request would exceed the daily cap, processing halts. Remaining requests stay queued for the next keeper call (when redeemedToday resets). Requests do not expire — they stay in the queue until processed or cancelled.
dailyCap = aggMarketNAV * dailyCapBps / 10000 — based on market NAV, not modeled NAV, to prevent artificial inflation during stress.
Example¶
Vault state:
aggModeledNAV = $2,000,000
aggMarketNAV = $1,900,000 (5% gap)
totalShares = 1,904,762 (sharePrice ≈ $1.05)
dailyCap = $38,000 (2% of aggMarketNAV)
redeemedToday = $0
Investor queued 10,000 shares.
Step 1: requestValue = 10,000 * 2,000,000 / 1,904,762 = $10,500
Step 2: $0 + $10,500 <= $38,000 — proceed
Step 3: fillBefore = 0 * 1e18 / 38,000e6 = 0
fillAfter = 10,500e6 * 1e18 / 38,000e6 ≈ 0.276e18
Step 4: curveNAV = _avgCurvePrice(0, 0.276e18, 2,000,000e6, 1,900,000e6)
≈ $1,968,000 (curve-weighted vault NAV)
Step 5: exitValue = 10,000 * 1,968,000e6 / 1,904,762 ≈ $10,332
Step 6: fee = ceiling($10,332 * 50 / 10,000) = $52
payout = $10,280
Investor receives: $10,280 USDC
HouseBuffer receives: $52 USDC