Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/Vault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity ^0.8.24;
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";

Expand All @@ -13,6 +14,8 @@ import { LibAllocator } from "./libraries/LibAllocator.sol";
import { LibFees } from "./libraries/LibFees.sol";
import { LibGuard } from "./libraries/LibGuard.sol";
import { LibLock } from "./libraries/LibLock.sol";
import { LibRoles } from "./libraries/LibRoles.sol";
import { LibWithdrawQueue } from "./libraries/LibWithdrawQueue.sol";

/// @title Vault Router is a modular ERC-4626 vault on the EIP-2535 Diamond pattern.
/// @notice Vault owns the ERC-4626 surface (deposit/withdraw/totalAssets) plus the
Expand All @@ -23,8 +26,20 @@ import { LibLock } from "./libraries/LibLock.sol";
/// virtual shares. The ERC-4626 surface is native (non-facet) and therefore
/// non-upgradeable, so it cannot be altered by a later diamondCut.
contract Vault is Diamond, ERC4626, ReentrancyGuard {
using SafeERC20 for IERC20;

error StrategyTotalAssetsCallFailed(bytes32 strategyId);

error WithdrawQueueZeroShares();
error WithdrawToZeroAddress();
error WithdrawRequestNotFound(uint256 id);
error NotRequestOwner(uint256 id, address caller);
error InsufficientIdleLiquidity(uint256 needed, uint256 available);

event WithdrawRequested(uint256 indexed id, address indexed owner, address indexed receiver, uint256 shares);
event WithdrawCancelled(uint256 indexed id, address indexed owner, uint256 shares);
event WithdrawFulfilled(uint256 indexed id, address indexed receiver, uint256 shares, uint256 assets);

constructor(
IERC20 asset_,
string memory name_,
Expand Down Expand Up @@ -186,4 +201,86 @@ contract Vault is Diamond, ERC4626, ReentrancyGuard {
f.lastFeeAccrual = nowTs;
if (feeShares > 0) _mint(f.feeRecipient, feeShares);
}

// -----------------------------------------------------------------------
// Async withdrawal queue
// -----------------------------------------------------------------------
// Synchronous `withdraw`/`redeem` pay from idle balance and revert when the
// vault is short — which happens when capital sits in an illiquid strategy
// (notably Pendle PT before maturity). The queue gives those exits a path:
// the requester escrows shares, a curator frees liquidity via `rebalance`,
// then fulfils the claim at the live share price. These live on the native
// surface because only it can `_transfer`/`_burn` shares; the readers are on
// `WithdrawQueueFacet`.

/// @notice Escrow `shares` for a later asynchronous exit to `receiver`.
/// @dev The shares are transferred to the vault (not burned), so the request
/// stays NAV-neutral and the requester keeps full exposure until
/// fulfillment — the claim is priced at fulfil time, not now. Subject to
/// the share lock: locked shares cannot be queued.
/// @param shares Amount of vault shares to escrow.
/// @param receiver Address that will receive the underlying on fulfillment.
/// @return id The request id, used to fulfil or cancel.
function requestWithdraw(uint256 shares, address receiver) external nonReentrant returns (uint256 id) {
if (shares == 0) revert WithdrawQueueZeroShares();
if (receiver == address(0)) revert WithdrawToZeroAddress();

// Escrow the shares (reverts on insufficient balance or active lock).
_transfer(msg.sender, address(this), shares);

LibWithdrawQueue.QueueStorage storage q = LibWithdrawQueue.queueStorage();
id = q.nextRequestId++;
q.requests[id] = LibWithdrawQueue.WithdrawRequest({ owner: msg.sender, receiver: receiver, shares: shares });
q.totalPendingShares += shares;

emit WithdrawRequested(id, msg.sender, receiver, shares);
}

/// @notice Cancel a pending request and return the escrowed shares to their owner.
/// @dev Only the request's owner may cancel, and only while it is unfulfilled.
/// @param id The request id returned by `requestWithdraw`.
function cancelWithdraw(uint256 id) external nonReentrant {
LibWithdrawQueue.QueueStorage storage q = LibWithdrawQueue.queueStorage();
LibWithdrawQueue.WithdrawRequest memory req = q.requests[id];
if (req.shares == 0) revert WithdrawRequestNotFound(id);
if (req.owner != msg.sender) revert NotRequestOwner(id, msg.sender);

q.totalPendingShares -= req.shares;
delete q.requests[id];

_transfer(address(this), req.owner, req.shares);

emit WithdrawCancelled(id, req.owner, req.shares);
}

/// @notice Fulfil a pending request: burn the escrowed shares and pay the
/// underlying to the recorded receiver.
/// @dev Curator-gated (the keeper seat). Honors the circuit breaker and
/// accrues fees first, so the payout reflects the live, post-fee share
/// price. Reverts if idle liquidity is insufficient — the curator must
/// `rebalance` enough out of strategies first. Effects precede the
/// transfer (CEI) and the function is `nonReentrant`.
/// @param id The request id to fulfil.
function fulfillWithdraw(uint256 id) external nonReentrant {
LibRoles.enforceIsCurator();

LibWithdrawQueue.QueueStorage storage q = LibWithdrawQueue.queueStorage();
LibWithdrawQueue.WithdrawRequest memory req = q.requests[id];
if (req.shares == 0) revert WithdrawRequestNotFound(id);

_guard();
_accrueFees();

uint256 assets = convertToAssets(req.shares);
uint256 idle = IERC20(asset()).balanceOf(address(this));
if (idle < assets) revert InsufficientIdleLiquidity(assets, idle);

q.totalPendingShares -= req.shares;
delete q.requests[id];

_burn(address(this), req.shares);
IERC20(asset()).safeTransfer(req.receiver, assets);

emit WithdrawFulfilled(id, req.receiver, req.shares, assets);
}
}
27 changes: 27 additions & 0 deletions src/facets/WithdrawQueueFacet.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { LibWithdrawQueue } from "../libraries/LibWithdrawQueue.sol";

/// @title WithdrawQueueFacet
/// @notice Public readers for the asynchronous withdrawal queue. The
/// share-moving entry points (`requestWithdraw`, `cancelWithdraw`,
/// `fulfillWithdraw`) live on the native `Vault` surface because only it
/// can `_transfer`/`_burn` shares; this facet exposes the queue state.
contract WithdrawQueueFacet {
/// @notice The request that will be assigned to the next `requestWithdraw`.
function nextWithdrawRequestId() external view returns (uint256) {
return LibWithdrawQueue.queueStorage().nextRequestId;
}

/// @notice Total escrowed shares across all unfulfilled requests.
function pendingWithdrawShares() external view returns (uint256) {
return LibWithdrawQueue.queueStorage().totalPendingShares;
}

/// @notice The stored request for `id`. A `shares == 0` result means the slot
/// is empty — never created, already fulfilled, or cancelled.
function withdrawRequest(uint256 id) external view returns (LibWithdrawQueue.WithdrawRequest memory) {
return LibWithdrawQueue.queueStorage().requests[id];
}
}
50 changes: 50 additions & 0 deletions src/libraries/LibWithdrawQueue.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title LibWithdrawQueue
/// @notice Namespaced storage for the asynchronous withdrawal queue. Lets users
/// exit when the vault lacks idle liquidity to satisfy a synchronous
/// ERC-4626 `withdraw`/`redeem` — most importantly when capital sits in
/// an illiquid strategy (e.g. Pendle PT before maturity). The user's
/// shares are escrowed on request and a curator/keeper fulfills them
/// once a rebalance has freed enough idle balance.
/// @dev Escrow model: shares are transferred to the diamond (not burned) on
/// request, so they remain in `totalSupply` and the request stays
/// NAV-neutral for the remaining holders. The claim is converted to assets
/// at the *live* share price on fulfillment — the requester keeps full
/// exposure (yield and loss) until they are actually paid out, so the queue
/// cannot be used to lock in a stale price at the expense of stayers.
///
/// The share-moving entry points (request/cancel/fulfill) live on Vault.sol
/// because only the native ERC-4626 surface can `_transfer`/`_burn` shares;
/// this library holds the queue state and `WithdrawQueueFacet` the readers.
/// Storage location:
/// keccak256(abi.encode(uint256(keccak256("vaultrouter.storage.withdrawqueue")) - 1)) & ~bytes32(uint256(0xff))
library LibWithdrawQueue {
/// @dev erc7201:vaultrouter.storage.withdrawqueue
bytes32 internal constant WITHDRAW_QUEUE_STORAGE_SLOT =
0x3d5a7857d3d4e9dbe18f39f41bd8fd54a510e284a7d9a4464e9cc2159e9f9100;

/// @notice A single pending exit. `shares == 0` marks the slot as
/// empty/settled (fulfilled or cancelled), so ids are never reused.
struct WithdrawRequest {
address owner; // who requested — receives the shares back on cancel
address receiver; // who receives the underlying on fulfillment
uint256 shares; // escrowed shares awaiting fulfillment
}

/// @custom:storage-location erc7201:vaultrouter.storage.withdrawqueue
struct QueueStorage {
uint256 nextRequestId;
mapping(uint256 => WithdrawRequest) requests;
/// @dev Sum of all escrowed shares currently awaiting fulfillment.
uint256 totalPendingShares;
}

function queueStorage() internal pure returns (QueueStorage storage s) {
bytes32 slot = WITHDRAW_QUEUE_STORAGE_SLOT;
assembly {
s.slot := slot
}
}
}
Loading