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
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
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
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
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)
First deposit bootstrapping
If
totalSupply() == 0, the caller must beowner()(via_checkOwner()), and the vault performs: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)
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.
stake / unstake (UX helpers)
withdraw (ERC-4626)
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)
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:
claimWithdraw
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
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:Set new vest:
vestingAmount = yieldAmount; lastDistributionTimestamp = timestamp;Emit
YieldDistributed(yieldAmount, timestamp).
Vesting accounting
totalAssets()excludes unvested yield: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
Sets the withdrawal cooldown. Reverts InvalidCooldown() if duration > MAX_COOLDOWN_DURATION. Emits CooldownDurationUpdated(prev, duration).
setMinAssetsAmount
Updates the minimum amount enforced for both deposits and withdrawals. Emits MinAssetsAmountUpdated(prev, new).
getCooldown (view)
Returns the pending withdrawal record for user.
getUnvestedAmount (view)
If
lastDistributionTimestamp > now, returns the fullvestingAmount.Else, computes the remaining unvested portion linearly over
VESTING_PERIOD = 8h.
totalAssets (view, ERC-4626)
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)
Enforces blacklist for mint and burn recipients/senders.
_withdraw (ERC-4626 core)
Enforces
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?

