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, SafeCast
AccessControl – 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
BURN
address 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 != 0
IERC20Metadata(_asset).decimals() == 8
Effects
cooldownDuration = MAX_COOLDOWN_DURATION
tokensHolder = new TokensHolder(address(this), address(_asset))
minAssetsAmount = _minAssetsAmount
distributor = _distributor
Grants
DEFAULT_ADMIN_ROLE
to_initialAdmin
(separate fromowner()
).
Important: The constructor does not grant
DISTRIBUTOR_ROLE
todistributor
. 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_SHARES
underlying to mintDEAD_SHARES
shares 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
_deposit
override (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 throughdeposit
for dead-shares bootstrap).Emits
Staked(msg.sender, previewMint(shares))
.Calls
super.mint
and 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.sender
via_withdrawTo(assets, msg.sender)
.Else:
Sets
cooldownEnd = uint104(block.timestamp) + cooldownDuration
formsg.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.sender
via_redeemTo(shares, msg.sender)
.Else:
Sets
cooldownEnd
formsg.sender
.Calls
_redeemTo(shares, address(tokensHolder))
and adds the computedassets
tocooldowns[msg.sender].underlyingAmount
.
Reverts
ZeroAmount()
if computedassets == 0
.
Event note:
_redeemTo
emitsUnstaked(msg.sender, shares)
(the second param is shares, not underlying assets). When integrating on events, be aware thatUnstaked
’sassets
argument 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
yieldAmount
of 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 onlyOwner
Sets the withdrawal cooldown. Reverts InvalidCooldown()
if duration > MAX_COOLDOWN_DURATION
. Emits CooldownDurationUpdated(prev, duration)
.
setMinAssetsAmount
function setMinAssetsAmount(uint256 _minAssetsAmount) external onlyOwner
Updates 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 > 0
andassets <= maxWithdraw(msg.sender)
.Computes
shares = previewWithdraw(assets)
and calls internal_withdraw(...)
.Emits
Unstaked(msg.sender, assets)
.
_redeemTo (internal)
Validates
shares > 0
andshares <= 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 setsvestingAmount
andlastDistributionTimestamp
.
_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 override
Enforces
assets >= minAssetsAmount
; elseAmountBelowLimit()
.Last-staker cleanup: if after burning
shares
onlyDEAD_SHARES
remain:Compute
remainingUnvested = getUnvestedAmount()
.If > 0: zero out
vestingAmount
/lastDistributionTimestamp
and 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