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

Invariants / Notes

  • Underlying decimals must be 8: the constructor requires IERC20Metadata(_asset).decimals() == decimals(), and decimals() returns 8.

  • 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

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 from owner()).

Important: The constructor does not grant DISTRIBUTOR_ROLE to distributor. Call setDistributor(...) once to grant the role before the first distributeYield.

Decimals

Events (from 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 guard

  • InvalidCooldown()

  • AmountBelowLimit()

  • IncompatibleDecimals() // constructor

  • NoStakers() // distribution requires live stakers

  • Standard 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 be owner() (via _checkOwner()), and the vault performs:

    i.e., owner funds DEAD_SHARES underlying to mint DEAD_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)

  • Requires shares > 0.

  • If totalSupply() == 0, reverts InitialMintNotAllowed() (first inflow must go through deposit 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.


stake / unstake (UX helpers)


withdraw (ERC-4626)

  • If cooldownDuration == 0: executes immediate withdrawal to msg.sender via _withdrawTo(assets, msg.sender).

  • Else:

    • Sets cooldownEnd = uint104(block.timestamp) + cooldownDuration for msg.sender.

    • Increments cooldowns[msg.sender].underlyingAmount += uint152(assets).

    • Executes _withdrawTo(assets, address(tokensHolder)) (moves underlying into the TokensHolder).

  • Reverts ZeroAmount() if computed shares == 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 to msg.sender via _redeemTo(shares, msg.sender).

  • Else:

    • Sets cooldownEnd for msg.sender.

    • Calls _redeemTo(shares, address(tokensHolder)) and adds the computed assets to cooldowns[msg.sender].underlyingAmount.

  • Reverts ZeroAmount() if computed assets == 0.

Event note: _redeemTo emits Unstaked(msg.sender, shares) (the second param is shares, not underlying assets). When integrating on events, be aware that Unstaked’s assets argument is populated with shares in this code path.

Convenience wrapper:


claimWithdraw

What it does

  • Reads assets = cooldowns[msg.sender].underlyingAmount; reverts ZeroAmount() if 0.

  • If block.timestamp >= cooldownEnd (or cooldownDuration == 0):

    • Resets the record (cooldownEnd = 0; underlyingAmount = 0).

    • Calls tokensHolder.withdraw(receiver, assets) (transfers underlying to receiver).

    • 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); else NoStakers().

  • Current unvested yield must be zero (getUnvestedAmount() == 0); else StillVesting().

Flow

  1. Pull yieldAmount of underlying from the caller (e.g., Minter) into the vault:

  2. Set new vest: vestingAmount = yieldAmount; lastDistributionTimestamp = timestamp;

  3. Emit YieldDistributed(yieldAmount, timestamp).

Vesting accounting

  • totalAssets() excludes unvested yield:

  • getUnvestedAmount() linearly decays to zero over an 8-hour window starting at timestamp.

    • 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 full vestingAmount.

  • 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 > 0 and assets <= maxWithdraw(msg.sender).

  • Computes shares = previewWithdraw(assets) and calls internal _withdraw(...).

  • Emits Unstaked(msg.sender, assets).

_redeemTo (internal)

  • Validates shares > 0 and shares <= 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 sets vestingAmount and lastDistributionTimestamp.

_update (ERC-20 hook)

  • Enforces blacklist for mint and burn recipients/senders.

_withdraw (ERC-4626 core)

  • Enforces assets >= minAssetsAmount; else AmountBelowLimit().

  • Last-staker cleanup: if after burning shares only DEAD_SHARES remain:

    • Compute remainingUnvested = getUnvestedAmount().

    • If > 0: zero out vestingAmount/lastDistributionTimestamp and transfer the remaining unvested yield to owner().

  • Calls super._withdraw(...) to finalize accounting and token movements.

_deposit (ERC-4626 core)

  • Enforces assets >= minAssetsAmount; else AmountBelowLimit().

  • Calls super._deposit(...).

Last updated

Was this helpful?