Skip to content

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.

  1. Investor calls requestWithdraw(shares, receiver).
  2. Vault transfers shares from msg.sender into escrow (held by vault from this point).
  3. Request stored in FIFO queue:
    queue[nextRequestId] = Request(
        owner:     msg.sender,
        receiver:  receiver,
        shares:    shares,
        timestamp: block.timestamp
    )
    
  4. nextRequestId increments.
  5. WithdrawRequested(requestId, owner, receiver, shares, timestamp) event emitted.
  6. 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.

  1. require(msg.sender == queue[requestId].owner)
  2. require(queue[requestId].shares > 0) — revert if already cancelled or processed.
  3. Vault returns escrowed shares to request.owner.
  4. queue[requestId].shares = 0 — tombstone. The entry is NOT deleted.
  5. 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:

  1. Compute request value:

    requestValue = request.shares * aggModeledNAV / totalShares
    

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

  3. Compute fill values (1e18 fixed-point — mandatory):

    fillBefore = redeemedToday * 1e18 / dailyCap
    fillAfter  = (redeemedToday + requestValue) * 1e18 / dailyCap
    
    !!! danger "1e18 scaling is mandatory" Do NOT write redeemedToday / dailyCap. Both values are USDC (6 dec) — integer division produces 0 for all partial fills, silently collapsing the curve to aggMarketNAV on every withdrawal.

  4. Compute curve-weighted aggregate vault NAV:

    curveNAV = _avgCurvePrice(fillBefore, fillAfter, aggModeledNAV, aggMarketNAV)
    
    curveNAV is a total vault valuation (USDC, 6 dec) between aggMarketNAV and aggModeledNAV. It is not a per-share price.

  5. Compute exit value:

    exitValue = request.shares * curveNAV / totalShares
    
    !!! danger "Divisor must be totalShares, not 1e18" curveNAV is a total vault valuation, not a per-share price. Writing request.shares * curveNAV / 1e18 produces a nonsensically small result.

  6. Compute fee (ceiling division):

    fee    = (exitValue * liquidityFeeBps + 9999) / 10000   // ceiling
    payout = exitValue - fee
    

  7. Reserve check:

    require(idleReserve >= exitValue)
    
    !!! danger "Failure reverts the entire transaction" If this require fails, all state changes from this call are rolled back. The queue is unchanged. The keeper must retry after the reserve is restored. Do NOT use try/catch or if/else to skip this request and continue — partial batch processing leaves accounting inconsistent. In normal operation this require should 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.

  8. Update accounting:

    idleReserve   -= exitValue
    redeemedToday += requestValue
    

  9. Pay out:

  10. Transfer payout USDC to request.receiver
  11. Transfer fee USDC to houseBuffer

  12. Finalise:

    • Burn request.shares from vault escrow
    • delete queue[nextProcessId]
    • Increment nextProcessId
    • Emit WithdrawProcessed(requestId, receiver, payout, fee, curveNAV)

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