Skip to content

Core Functions

The five core functions that investors, the keeper, and governance interact with directly.


deposit

function deposit(uint256 assets, address receiver) external

Access: Any address

No pause check — deposits are always accepted regardless of pause state. The vault can receive capital at any time.

Description: Accept USDC from a depositor, mint vault shares to the receiver. Capital stays idle in idleReserve until the operator deploys it via openPosition().

Behavior:

  1. Transfers assets USDC from msg.sender to the vault (requires prior ERC-20 approval)
  2. Computes shares to mint:
    if totalShares == 0:
        sharesToMint = assets × 1e12        // first-deposit initialisation rule
    else:
        sharesToMint = assets × totalShares / aggModeledNav()   // floor division
    
    The 1e12 factor on the first deposit aligns USDC (6 decimals) with shares (18 decimals). See Section 6.10 — Arithmetic Precision.
  3. Adds assets to idleReserve
  4. Mints sharesToMint to receiver
  5. Emits Deposit(sender, receiver, assets, shares)

Decoupled from deployment

Deposit and capital deployment are intentionally decoupled. The vault can accept deposits at any time without needing a ready market. The operator deploys idle capital when conditions are suitable — not when deposits arrive.


requestWithdraw

function requestWithdraw(uint256 shares, address receiver) external returns (uint256 requestId)

Access: Any address (shares are pulled from msg.sender)

Description: Queue a withdrawal request. Returns a requestId for tracking. No immediate USDC payout — the request joins the FIFO queue and is processed by the keeper.

Behavior:

  1. Assigns requestId = nextRequestId, then increments nextRequestId
  2. Transfers shares from msg.sender to the vault (escrowed — held by vault from this point)
  3. Stores queue[requestId] = Request(owner=msg.sender, receiver, shares, timestamp=block.timestamp)
  4. Emits WithdrawRequested(requestId, owner, receiver, shares, timestamp)
  5. Returns requestId

Shares are locked at request time

Shares are transferred to the vault at the moment requestWithdraw is called — not at processing time. Once escrowed, they cannot be transferred, used as collateral, or queued again until the request is processed or cancelled.


processWithdrawals

function processWithdrawals(uint256 maxCount) external

Access: Keeper only — require(msg.sender == keeper)

Description: Process up to maxCount queued withdrawal requests in FIFO order. Computes exact average exit prices using the closed-form quadratic curve integral, pays USDC from idleReserve, sends fees to HouseBuffer, burns shares.

Execution sequence (12 steps per live request):

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 defined as an 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 the system is 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 requestValue = request.shares * aggModeledNAV / totalShares
  2. If redeemedToday + requestValue > dailyCap: stop — do not process this or any subsequent request in this call
  3. Compute fill values scaled to 1e18 fixed-point:
    fillBefore = redeemedToday * 1e18 / dailyCap
    fillAfter  = (redeemedToday + requestValue) * 1e18 / dailyCap
    
    !!! danger "1e18 scaling is mandatory" Do NOT write fillBefore = redeemedToday / dailyCap. Both redeemedToday and dailyCap are USDC (6 dec). Integer division produces 0 for all partial fills, silently collapsing the exit curve to aggMarketNAV on every withdrawal. The 1e18 scaling is required so the values are treated as fixed-point in [0, 1e18].
  4. Compute curveNAV = _avgCurvePrice(fillBefore, fillAfter, aggModeledNAV, aggMarketNAV) — the curve-weighted aggregate vault NAV (USDC, 6 dec). This is a total vault valuation between aggMarketNAV and aggModeledNAV. It is not a per-share price.
  5. Compute exitValue = request.shares * curveNAV / totalShares — the USDC owed to this share count at the curve price. !!! danger "Divisor must be totalShares, not 1e18" Do NOT write request.shares * curveNAV / 1e18. curveNAV is a total vault valuation in USDC, not a per-share price. Dividing by 1e18 would produce a nonsensically small result. The correct divisor is totalShares.
  6. Compute fee = ceiling((exitValue * liquidityFeeBps) / 10000) — rounds up, favours HouseBuffer
  7. Compute payout = exitValue - fee
  8. require(idleReserve >= exitValue) — if this fails, revert the entire transaction. All state changes from this call are rolled back. The queue is unchanged. The keeper must retry after the reserve is restored. !!! danger "No skip-and-continue on reserve failure" Do NOT use try/catch or if/else to skip this request and continue the batch. 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 requiring investigation.
  9. idleReserve -= exitValue; redeemedToday += requestValue
  10. Transfer payout USDC to request.receiver; transfer fee USDC to houseBuffer
  11. Burn request.shares from vault escrow; delete queue[nextProcessId]; increment nextProcessId; emit WithdrawProcessed(requestId, receiver, payout, fee, curveNAV)

Post-loop: If idleReserve < aggMarketNAV * reserveTargetBps / 10000 / 2, call houseBuffer.topUpReserve() and emit ReserveTopupRequested(amount).

Daily cap stops processing

When a request would exceed the daily cap, processing halts for the entire call. The request is not partially filled — it will be the first request processed on the next keeper call when redeemedToday resets.


cancelWithdraw

function cancelWithdraw(uint256 requestId) external

Access: Original request owner only — queue[requestId].owner

Description: Cancel a pending withdrawal request. Returns escrowed shares to the original requester.

Behavior:

  1. require(msg.sender == queue[requestId].owner)
  2. require(queue[requestId].shares > 0) — revert if already cancelled or processed
  3. Transfer request.shares back to request.owner
  4. delete queue[requestId] — zeroes all struct fields including shares
  5. requestStatus[requestId] = CANCELLED — marks the slot as cancelled in the status mapping
  6. Emit WithdrawCancelled(requestId, owner, sharesReturned)

Delete + status, not tombstone

cancelWithdraw uses delete rather than setting shares = 0 directly. The deleted struct has shares == 0, so processWithdrawals's skip logic (queue[pid].shares == 0) behaves identically. The requestStatus mapping provides an explicit CANCELLED state that getRequest uses to return a typed error rather than a zeroed struct.

Shares returned, not assets

Cancellation returns the original shares, not USDC. The USDC value of those shares may differ from when the request was submitted, as the share price reflects ongoing NAV changes.


setKeeper

function setKeeper(address newKeeper) external

Access: Governance only

Description: Rotate the keeper address. The new address immediately becomes the sole authorised caller of processWithdrawals.

Behavior:

  1. require(msg.sender == governance)
  2. require(newKeeper != address(0)) — zero address is not permitted; it would break processWithdrawals
  3. Sets keeper = newKeeper
  4. Emits KeeperUpdated(oldKeeper, newKeeper)

setOperator

function setOperator(address newOperator) external

Access: Governance only

Description: Rotate the operator address. The new address immediately gains access to all position management functions. Using the same address as governance collapses to the original two-role model.

Behavior:

  1. require(msg.sender == governance)
  2. require(newOperator != address(0))
  3. Sets operator = newOperator
  4. Emits OperatorUpdated(oldOperator, newOperator)

proposeGovernance

function proposeGovernance(address newGovernance) external

Access: Governance only

Description: Initiate a governance transfer. Sets pendingGovernance to the proposed address. Does not transfer governance immediately — the proposed address must call acceptGovernance() to complete the transfer.

Behavior:

  1. require(msg.sender == governance)
  2. Sets pendingGovernance = newGovernance
  3. Emits GovernanceProposed(currentGovernance, proposedGovernance)

No enforced time delay

The two-step pattern prevents fat-finger transfers to wrong addresses, but there is no on-chain delay between proposal and acceptance. A compromised governance key can transfer in a single block if the attacker also controls the target address. Multisig governance mitigates this risk.


acceptGovernance

function acceptGovernance() external

Access: pendingGovernance address only

Description: Complete a pending governance transfer. The proposed address calls this function to accept governance. Clears pendingGovernance on completion.

Behavior:

  1. require(msg.sender == pendingGovernance)
  2. Sets governance = pendingGovernance
  3. Clears pendingGovernance = address(0)
  4. Emits GovernanceTransferred(oldGovernance, newGovernance)