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(), 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

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

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

Decimals

function decimals() public pure override returns (uint8) { return 8; }

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

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

    super.deposit(DEAD_SHARES, BURN);

    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)

function mint(uint256 shares, address receiver)
  public override returns (uint256 assets)
  • 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.

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 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)

function redeem(uint256 shares, address /*receiver*/, address /*owner*/)
  public override returns (uint256 assets)
  • 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:

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; 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

function distributeYield(uint256 yieldAmount, uint256 timestamp)
  external onlyRole(DISTRIBUTOR_ROLE)

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:

    IERC20(asset()).safeTransferFrom(msg.sender, address(this), yieldAmount);
  2. Set new vest: vestingAmount = yieldAmount; lastDistributionTimestamp = timestamp;

  3. 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 at timestamp.

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

  • 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 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)

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; 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