Skip to content

Add ERC4626 Async Vaults #5710

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
196 changes: 196 additions & 0 deletions contracts/token/ERC20/extensions/ERC4626AsyncDeposit.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC4626} from "./ERC4626.sol";
import {Math} from "../../../utils/math/Math.sol";
import {IERC20} from "../ERC20.sol";

/**
* @dev Extension of {ERC4626} that supports asynchronous deposit flows.
*
* This extension implements a time-based delay mechanism for deposits where assets are queued and become
* gradually available for deposit over a configurable delay period. This provides protection against
* various forms of economic attacks and allows for controlled capital inflows.
*
* The async deposit mechanism works as follows:
* 1. Users queue assets using {queueDeposit} or {queueMint}
* 2. Queued assets become gradually available for deposit over time according to {depositDelay}
* 3. Users call standard {deposit} or {mint} functions to claim available shares
*
* The availability schedule is linear: if a user queues assets at time T with delay D, then at time T+x,
* the fraction x/D of the queued assets will be available for deposit, up to a maximum of all queued assets
* when x >= D.
*
* Multiple queued deposits are tracked using a weighted average timestamp to ensure fair treatment of
* deposits made at different times.
*
* [CAUTION]
* ====
* This extension modifies the behavior of {maxDeposit} and {maxMint} to respect the async schedule.
* The {deposit} and {mint} functions will revert if called with amounts exceeding the currently available
* queued assets, even if the user has sufficient balance and the vault would normally accept the deposit.
* ====
*/
abstract contract ERC4626AsyncDeposit is ERC4626 {
/**
* @dev Mapping from owner to the weighted average timestamp of their queued assets.
* This timestamp represents the "center of mass" of all queued deposits over time.
*/
mapping(address owner => uint48) private _averageQueueTimepoint;

/**
* @dev Mapping from owner to the total amount of assets they have queued for deposit.
*/
mapping(address owner => uint256) private _queuedAssets;

/**
* @dev Emitted when assets are queued for asynchronous deposit.
*/
event DepositQueued(address indexed owner, uint256 assets);

/**
* @dev Returns the delay period for deposits. Assets queued at time T will be fully available
* for deposit at time T + depositDelay(owner).
*
* The default implementation returns 1 day for all users. Override this function to implement
* custom delay logic, such as different delays for different users or dynamic delays based on
* market conditions.
*/
function depositDelay(address /* owner */) public view virtual returns (uint256) {
return 1 days;
}

// ==== Deposit ====

/**
* @dev Returns the maximum amount of the underlying asset that can be deposited into the vault for the `owner`,
* through a {deposit} call.
*
* This function considers both the standard ERC4626 deposit limits and the async deposit schedule.
* The returned value is the minimum of:
* - The standard {maxDeposit} limit
* - The amount of queued assets currently available according to the deposit schedule
*/
function maxDeposit(address owner) public view virtual override returns (uint256) {
return Math.min(super.maxDeposit(owner), _depositSchedule(_queuedAssets[owner], owner));
}

/**
* @dev Queue assets for asynchronous deposit.
*
* The queued assets will become gradually available for deposit over the period specified by {depositDelay}.
* Multiple calls to this function will update the weighted average timestamp of all queued assets.
*
* Requirements:
* - The caller must have sufficient balance of the underlying asset
* - The total queued assets cannot exceed the caller's balance
*
* Emits a {DepositQueued} event.
*/
function queueDeposit(uint256 assets, address owner) public virtual {
_queueAssets(assets, owner);
}

/**
* @dev Deposits assets to the vault and mints shares to receiver.
*
* This function will consume from the caller's queued assets. The amount must not exceed
* the currently available queued assets as determined by the deposit schedule.
*
* Requirements:
* - All standard ERC4626 {deposit} requirements
* - The caller must have sufficient queued assets available according to the schedule
*/
function deposit(uint256 assets, address receiver) public virtual override returns (uint256) {
_queuedAssets[_msgSender()] -= assets;
return super.deposit(assets, receiver);
}

// ==== Mint ====

/**
* @dev Returns the maximum amount of shares that can be minted from the vault for the `owner`,
* through a {mint} call.
*
* This function considers both the standard ERC4626 mint limits and the async deposit schedule.
* The returned value is the minimum of:
* - The standard {maxMint} limit
* - The amount of shares that can be minted with currently available queued assets
*/
function maxMint(address owner) public view virtual override returns (uint256) {
return Math.min(super.maxMint(owner), _depositSchedule(_queuedAssets[owner], owner));
}

/**
* @dev Queue assets for asynchronous minting of specific share amount.
*
* This function calculates the required assets using {previewMint} and queues them.
* The queued assets will become gradually available for minting over the period specified by {depositDelay}.
*
* Requirements:
* - The caller must have sufficient balance to cover the required assets for minting `shares`
* - The total queued assets cannot exceed the caller's balance
*
* Emits a {DepositQueued} event.
*/
function queueMint(uint256 shares, address owner) public virtual {
_queueAssets(previewMint(shares), owner);
}

/**
* @dev Mints exactly `shares` vault shares to `receiver` by consuming queued assets.
*
* This function will consume from the caller's queued assets. The required assets must not exceed
* the currently available queued assets as determined by the deposit schedule.
*
* Requirements:
* - All standard ERC4626 {mint} requirements
* - The caller must have sufficient queued assets available to cover the required assets for minting
*/
function mint(uint256 shares, address receiver) public virtual override returns (uint256) {
uint256 minted = super.mint(shares, receiver);
_queuedAssets[_msgSender()] -= minted;
return minted;
}

// ==== Internal ====

/**
* @dev Internal function to queue assets for async deposit.
*
* This function updates the queued assets amount and recalculates the weighted average timestamp.
* The new average timestamp gives proportional weight to existing queued assets and newly queued assets
* based on their amounts.
*
* The queued amount is capped at the owner's current balance of the underlying asset to prevent
* queueing more assets than the user actually possesses.
*/
function _queueAssets(uint256 assets, address owner) internal virtual {
uint256 queuedAssets = _queuedAssets[owner];
uint256 newQueuedAssets = Math.min(queuedAssets + assets, IERC20(asset()).balanceOf(owner));
uint256 previousAverageTimestamp = _averageQueueTimepoint[owner];

// Safe down cast as timestamp fits in uint48
_averageQueueTimepoint[owner] = uint48(
Math.mulDiv(previousAverageTimestamp, queuedAssets, newQueuedAssets) +
Math.mulDiv(block.timestamp, assets, newQueuedAssets)
);

emit DepositQueued(owner, assets);
}

/**
* @dev Internal function to calculate the current deposit schedule for queued assets.
*
* Returns the amount of queued assets currently available for deposit based on the time elapsed
* since the weighted average queue timestamp and the deposit delay period.
*
* The calculation is: availableAssets = queuedAssets * min(1, timeElapsed / depositDelay)
*
* This provides a linear release schedule where assets become fully available after the delay period.
*/
function _depositSchedule(uint256 queuedAssets, address owner) internal view virtual returns (uint256) {
return Math.mulDiv(queuedAssets, block.timestamp - _averageQueueTimepoint[owner], depositDelay(owner));
}
}
196 changes: 196 additions & 0 deletions contracts/token/ERC20/extensions/ERC4626AsyncWithdraw.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC4626} from "./ERC4626.sol";
import {Math} from "../../../utils/math/Math.sol";
import {IERC20} from "../ERC20.sol";

/**
* @dev Extension of {ERC4626} that supports asynchronous withdrawal flows.
*
* This extension implements a time-based delay mechanism for withdrawals where shares are queued and become
* gradually available for redemption over a configurable delay period. This provides protection against
* bank runs and allows for controlled capital outflows.
*
* The async withdrawal mechanism works as follows:
* 1. Users queue shares for withdrawal using {queueRedeem} or {queueWithdraw}
* 2. Queued shares become gradually available for redemption over time according to {withdrawDelay}
* 3. Users call standard {redeem} or {withdraw} functions to claim available assets
*
* The availability schedule is linear: if a user queues shares at time T with delay D, then at time T+x,
* the fraction x/D of the queued shares will be available for withdrawal, up to a maximum of all queued shares
* when x >= D.
*
* Multiple queued withdrawals are tracked using a weighted average timestamp to ensure fair treatment of
* withdrawal requests made at different times.
*
* [CAUTION]
* ====
* This extension modifies the behavior of {maxWithdraw} and {maxRedeem} to respect the async schedule.
* The {withdraw} and {redeem} functions will revert if called with amounts exceeding the currently available
* queued shares, even if the user has sufficient share balance and the vault would normally allow the withdrawal.
* ====
*/
abstract contract ERC4626AsyncWithdraw is ERC4626 {
/**
* @dev Mapping from owner to the weighted average timestamp of their queued shares.
* This timestamp represents the "center of mass" of all queued withdrawal requests over time.
*/
mapping(address owner => uint48) private _averageQueueTimepoint;

/**
* @dev Mapping from owner to the total amount of shares they have queued for withdrawal.
*/
mapping(address owner => uint256) private _queuedShares;

/**
* @dev Emitted when shares are queued for asynchronous withdrawal.
*/
event WithdrawQueued(address indexed owner, uint256 shares);

/**
* @dev Returns the delay period for withdrawals. Shares queued at time T will be fully available
* for withdrawal at time T + withdrawDelay(owner).
*
* The default implementation returns 1 day for all users. Override this function to implement
* custom delay logic, such as different delays for different users or dynamic delays based on
* market conditions or liquidity requirements.
*/
function withdrawDelay(address /* owner */) public view virtual returns (uint256) {
return 1 days;
}

// ==== Redeem ====

/**
* @dev Returns the maximum amount of shares that can be redeemed from the `owner` balance in the vault,
* through a {redeem} call.
*
* This function considers both the standard ERC4626 redemption limits and the async withdrawal schedule.
* The returned value is the minimum of:
* - The standard {maxRedeem} limit
* - The amount of queued shares currently available according to the withdrawal schedule
*/
function maxRedeem(address owner) public view virtual override returns (uint256) {
return Math.min(super.maxRedeem(owner), _withdrawSchedule(_queuedShares[owner], owner));
}

/**
* @dev Queue shares for asynchronous redemption.
*
* The queued shares will become gradually available for redemption over the period specified by {withdrawDelay}.
* Multiple calls to this function will update the weighted average timestamp of all queued shares.
*
* Requirements:
* - The caller must have sufficient share balance
* - The total queued shares cannot exceed the caller's share balance
*
* Emits a {WithdrawQueued} event.
*/
function queueRedeem(uint256 shares, address owner) public virtual {
_queueShares(shares, owner);
}

/**
* @dev Burns exactly `shares` from `owner` and sends assets of underlying tokens to `receiver`.
*
* This function will consume from the owner's queued shares. The amount must not exceed
* the currently available queued shares as determined by the withdrawal schedule.
*
* Requirements:
* - All standard ERC4626 {redeem} requirements
* - The owner must have sufficient queued shares available according to the schedule
*/
function redeem(uint256 shares, address receiver, address owner) public virtual override returns (uint256) {
_queuedShares[owner] -= shares;
return super.redeem(shares, receiver, owner);
}

// ==== Withdraw ====

/**
* @dev Returns the maximum amount of the underlying asset that can be withdrawn from the `owner` balance
* in the vault, through a {withdraw} call.
*
* This function considers both the standard ERC4626 withdrawal limits and the async withdrawal schedule.
* The returned value is the minimum of:
* - The standard {maxWithdraw} limit
* - The amount of assets that can be withdrawn with currently available queued shares
*/
function maxWithdraw(address owner) public view virtual override returns (uint256) {
return Math.min(super.maxWithdraw(owner), _withdrawSchedule(_queuedShares[owner], owner));
}

/**
* @dev Queue shares for asynchronous withdrawal of specific asset amount.
*
* This function calculates the required shares using {previewWithdraw} and queues them.
* The queued shares will become gradually available for withdrawal over the period specified by {withdrawDelay}.
*
* Requirements:
* - The caller must have sufficient share balance to cover the required shares for withdrawing `assets`
* - The total queued shares cannot exceed the caller's share balance
*
* Emits a {WithdrawQueued} event.
*/
function queueWithdraw(uint256 assets, address owner) public virtual {
_queueShares(previewWithdraw(assets), owner);
}

/**
* @dev Burns shares from `owner` and sends exactly `assets` of underlying tokens to `receiver`.
*
* This function will consume from the owner's queued shares. The required shares must not exceed
* the currently available queued shares as determined by the withdrawal schedule.
*
* Requirements:
* - All standard ERC4626 {withdraw} requirements
* - The owner must have sufficient queued shares available to cover the required shares for withdrawal
*/
function withdraw(uint256 assets, address receiver, address owner) public virtual override returns (uint256) {
uint256 withdrawn = super.withdraw(assets, receiver, owner);
_queuedShares[owner] -= withdrawn;
return withdrawn;
}

// ==== Internal ====

/**
* @dev Internal function to queue shares for async withdrawal.
*
* This function updates the queued shares amount and recalculates the weighted average timestamp.
* The new average timestamp gives proportional weight to existing queued shares and newly queued shares
* based on their amounts.
*
* The queued amount is capped at the owner's current share balance to prevent queueing more shares
* than the user actually possesses.
*/
function _queueShares(uint256 shares, address owner) internal virtual {
uint256 queuedShares = _queuedShares[owner];
uint256 newQueuedShares = Math.min(queuedShares + shares, balanceOf(owner));
uint256 previousAverageTimestamp = _averageQueueTimepoint[owner];

// Safe down cast as timestamp fits in uint48
_averageQueueTimepoint[owner] = uint48(
Math.mulDiv(previousAverageTimestamp, queuedShares, newQueuedShares) +
Math.mulDiv(block.timestamp, shares, newQueuedShares)
);

emit WithdrawQueued(owner, shares);
}

/**
* @dev Internal function to calculate the current withdrawal schedule for queued shares.
*
* Returns the amount of queued shares currently available for withdrawal based on the time elapsed
* since the weighted average queue timestamp and the withdrawal delay period.
*
* The calculation is: availableShares = queuedShares * min(1, timeElapsed / withdrawDelay)
*
* This provides a linear release schedule where shares become fully available after the delay period.
*/
function _withdrawSchedule(uint256 queuedShares, address owner) internal view virtual returns (uint256) {
return Math.mulDiv(queuedShares, block.timestamp - _averageQueueTimepoint[owner], withdrawDelay(owner));
}
}
Loading