Staking Vault
Purpose
StakingVault is an ERC-4626 vault over an 8-decimals underlying (the synthetic token). Depositors receive share tokens (same 8 decimals). The vault:
Enforces a withdrawal cooldown (single delay; no partial vest on withdrawals).
Supports yield distributions that vest linearly over 8 hours into the share price.
Enforces blacklisting on share mints/burns and claims.
Imports
ERC4626, ERC20, AccessControl, Blacklistable, SafeERC20, SafeCastAccessControl – for distributor/admin separation.
Blacklistable (Ownable) – vault-level blacklisting (mints/burns/claims).
ERC4626 – tokenized vault accounting.
ERC20 – the share token implementation.
Roles & Constants
bytes32 public constant DISTRIBUTOR_ROLE = keccak256("DISTRIBUTOR_ROLE");
uint256 private constant VESTING_PERIOD = 8 hours; // yield vesting
uint24 public constant MAX_COOLDOWN_DURATION = 90 days;
uint256 internal constant DEAD_SHARES = 1000;
address constant BURN = 0x000000000000000000000000000000000000dEaD;Storage
uint256 public vestingAmount; // unvested yield total (set at distribution)
uint256 public minAssetsAmount; // floor for deposit/withdraw amounts
uint24 public cooldownDuration; // withdrawal cooldown (seconds)
address public distributor; // current distributor address (for reference)
uint256 public lastDistributionTimestamp; // vest start (can be in future)
TokensHolder public immutable tokensHolder; // holds pending withdrawals’ underlying
mapping(address => UserCooldown) cooldowns; // per-user pending withdrawal
struct UserCooldown {
uint104 cooldownEnd; // UNIX timestamp when claim is allowed
uint152 underlyingAmount; // one-shot amount available at claim
}Invariants / Notes
Underlying decimals must be 8: the constructor requires
IERC20Metadata(_asset).decimals() == decimals(), anddecimals()returns8.First deposit: mints DEAD_SHARES to the
BURNaddress by making the owner perform a special initial deposit (see below), preventing zero-TVL edge cases.Cooldown default: set to
MAX_COOLDOWN_DURATION(90 days) at deployment; owner can shorten/raise (up to the max).
Constructor
constructor(
IERC20 _asset,
string memory _name,
string memory _symbol,
address _initialAdmin,
uint256 _minAssetsAmount,
address _distributor
)
ERC20(_name, _symbol)
ERC4626(_asset)
Ownable(_initialAdmin)Requirements
_initialAdmin != 0IERC20Metadata(_asset).decimals() == 8
Effects
cooldownDuration = MAX_COOLDOWN_DURATIONtokensHolder = new TokensHolder(address(this), address(_asset))minAssetsAmount = _minAssetsAmountdistributor = _distributorGrants
DEFAULT_ADMIN_ROLEto_initialAdmin(separate fromowner()).
Important: The constructor does not grant
DISTRIBUTOR_ROLEtodistributor. CallsetDistributor(...)once to grant the role before the firstdistributeYield.
Decimals
function decimals() public pure override returns (uint8) { return 8; }Events (from IStakingVault)
IStakingVault)event Staked(address indexed user, uint256 assets);event Unstaked(address indexed user, uint256 assets);event YieldDistributed(uint256 amount, uint256 timestamp);event WithdrawClaimed(address indexed user, uint256 assets);event MinAssetsAmountUpdated(uint256 previousAmount, uint256 newAmount);event CooldownDurationUpdated(uint24 previousDuration, uint24 newDuration);
Errors
ZeroAmount()InitialMintNotAllowed()TransfersNotAllowed()StillVesting()// yield distribution overlap guardInvalidCooldown()AmountBelowLimit()IncompatibleDecimals()// constructorNoStakers()// distribution requires live stakersStandard ERC-4626 errors (e.g.,
ERC4626ExceededMaxWithdraw,ERC4626ExceededMaxRedeem)
Public / External
setDistributor
function setDistributor(address newDistributor)
external onlyRole(DEFAULT_ADMIN_ROLE)Validates non-zero, revokes DISTRIBUTOR_ROLE from the old distributor, sets storage, grants DISTRIBUTOR_ROLE to the new one.
Call this once after deployment to actually grant the role to the intended distributor.
deposit (ERC-4626)
function deposit(uint256 assets, address receiver)
public override returns (uint256 shares)First deposit bootstrapping
If
totalSupply() == 0, the caller must beowner()(via_checkOwner()), and the vault performs:super.deposit(DEAD_SHARES, BURN);i.e., owner funds
DEAD_SHARESunderlying to mintDEAD_SHARESshares to the burn address.
Then
Requires
assets > 0.Emits
Staked(msg.sender, assets).Calls standard ERC-4626
super.deposit(assets, receiver).Reverts
ZeroAmount()if computed shares == 0.
Min amount
Enforced in
_depositoverride (see below):assets >= minAssetsAmount.
mint (ERC-4626)
function mint(uint256 shares, address receiver)
public override returns (uint256 assets)Requires
shares > 0.If
totalSupply() == 0, revertsInitialMintNotAllowed()(first inflow must go throughdepositfor dead-shares bootstrap).Emits
Staked(msg.sender, previewMint(shares)).Calls
super.mintand reverts if computed assets == 0.Min amount floor enforced in
_deposit.
function mint(uint256 shares) external returns (uint256) {
return mint(shares, msg.sender);
}stake / unstake (UX helpers)
function stake(uint256 assets) external returns (uint256) {
return deposit(assets, msg.sender);
}
function unstake(uint256 assets) external returns (uint256 shares) {
return withdraw(assets, msg.sender, msg.sender);
}withdraw (ERC-4626)
function withdraw(uint256 assets, address /*receiver*/, address /*owner*/)
public override returns (uint256 shares)If
cooldownDuration == 0: executes immediate withdrawal tomsg.sendervia_withdrawTo(assets, msg.sender).Else:
Sets
cooldownEnd = uint104(block.timestamp) + cooldownDurationformsg.sender.Increments
cooldowns[msg.sender].underlyingAmount += uint152(assets).Executes
_withdrawTo(assets, address(tokensHolder))(moves underlying into theTokensHolder).
Reverts
ZeroAmount()if computedshares == 0.
Min amount
Enforced in
_withdraw:assets >= minAssetsAmount.
Claiming
The actual underlying becomes claimable after the cooldown via
claimWithdraw.
redeem (ERC-4626)
function redeem(uint256 shares, address /*receiver*/, address /*owner*/)
public override returns (uint256 assets)If
cooldownDuration == 0: executes immediate redeem tomsg.sendervia_redeemTo(shares, msg.sender).Else:
Sets
cooldownEndformsg.sender.Calls
_redeemTo(shares, address(tokensHolder))and adds the computedassetstocooldowns[msg.sender].underlyingAmount.
Reverts
ZeroAmount()if computedassets == 0.
Event note:
_redeemToemitsUnstaked(msg.sender, shares)(the second param is shares, not underlying assets). When integrating on events, be aware thatUnstaked’sassetsargument is populated with shares in this code path.
Convenience wrapper:
function redeem(uint256 shares) external returns (uint256 assets) {
return redeem(shares, msg.sender, msg.sender);
}claimWithdraw
function claimWithdraw(address receiver)
external notBlacklisted(msg.sender)What it does
Reads
assets = cooldowns[msg.sender].underlyingAmount; revertsZeroAmount()if 0.If
block.timestamp >= cooldownEnd(orcooldownDuration == 0):Resets the record (
cooldownEnd = 0; underlyingAmount = 0).Calls
tokensHolder.withdraw(receiver, assets)(transfers underlying toreceiver).Emits
WithdrawClaimed(msg.sender, assets).
Else reverts
InvalidCooldown().
Withdrawal is one-shot after the cooldown; there’s no linear vesting of withdrawn amounts.
distributeYield
function distributeYield(uint256 yieldAmount, uint256 timestamp)
external onlyRole(DISTRIBUTOR_ROLE)Requirements
totalSupply() > DEAD_SHARES(must have at least one real staker); elseNoStakers().Current unvested yield must be zero (
getUnvestedAmount() == 0); elseStillVesting().
Flow
Pull
yieldAmountof underlying from the caller (e.g., Minter) into the vault:IERC20(asset()).safeTransferFrom(msg.sender, address(this), yieldAmount);Set new vest:
vestingAmount = yieldAmount; lastDistributionTimestamp = timestamp;Emit
YieldDistributed(yieldAmount, timestamp).
Vesting accounting
totalAssets()excludes unvested yield:totalAssets() = balanceOfUnderlying(this) - getUnvestedAmount();getUnvestedAmount()linearly decays to zero over an 8-hour window starting attimestamp.If
timestamp > block.timestamp, the amount remains fully unvested until that future time.
setCooldownDuration
function setCooldownDuration(uint24 duration) external onlyOwnerSets the withdrawal cooldown. Reverts InvalidCooldown() if duration > MAX_COOLDOWN_DURATION. Emits CooldownDurationUpdated(prev, duration).
setMinAssetsAmount
function setMinAssetsAmount(uint256 _minAssetsAmount) external onlyOwnerUpdates the minimum amount enforced for both deposits and withdrawals. Emits MinAssetsAmountUpdated(prev, new).
getCooldown (view)
function getCooldown(address user)
external view returns (UserCooldown memory)Returns the pending withdrawal record for user.
getUnvestedAmount (view)
function getUnvestedAmount() public view returns (uint256)If
lastDistributionTimestamp > now, returns the fullvestingAmount.Else, computes the remaining unvested portion linearly over
VESTING_PERIOD = 8h.
totalAssets (view, ERC-4626)
function totalAssets() public view override returns (uint256)Returns IERC20(asset()).balanceOf(this) - getUnvestedAmount().
Internal Hooks / Overrides
_withdrawTo (internal)
Validates
assets > 0andassets <= maxWithdraw(msg.sender).Computes
shares = previewWithdraw(assets)and calls internal_withdraw(...).Emits
Unstaked(msg.sender, assets).
_redeemTo (internal)
Validates
shares > 0andshares <= maxRedeem(msg.sender).Computes
assets = previewRedeem(shares)and calls internal_withdraw(...).Emits
Unstaked(msg.sender, shares)(shares value, see note above).
_updateVestingAmount (internal)
Requires
getUnvestedAmount() == 0; then setsvestingAmountandlastDistributionTimestamp.
_update (ERC-20 hook)
function _update(address from, address to, uint256 amount)
internal override notBlacklisted(from) notBlacklisted(to)
{
super._update(from, to, amount);
}Enforces blacklist for mint and burn recipients/senders.
_withdraw (ERC-4626 core)
function _withdraw(
address caller, address receiver, address _owner,
uint256 assets, uint256 shares
) internal overrideEnforces
assets >= minAssetsAmount; elseAmountBelowLimit().Last-staker cleanup: if after burning
sharesonlyDEAD_SHARESremain:Compute
remainingUnvested = getUnvestedAmount().If > 0: zero out
vestingAmount/lastDistributionTimestampand transfer the remaining unvested yield toowner().
Calls
super._withdraw(...)to finalize accounting and token movements.
_deposit (ERC-4626 core)
Enforces
assets >= minAssetsAmount; elseAmountBelowLimit().Calls
super._deposit(...).
Last updated
Was this helpful?

