Staking protocols sit at the intersection of consensus layer economics and smart contract execution. With approximately 25% of ETH currently staked and the smart contract market projected to reach $73 billion by 2030, the stakes have never been higher for ensuring the security of staking mechanisms. As of April 2026, total liquid staking TVL stands at $42.09 billion, making it one of the largest sectors in all of decentralized finance. The complexity of these systems — pooled deposits, validator coordination, reward accounting, withdrawal queues, and LST exchange rates — creates compounding attack surfaces that are not always visible in any single component.
This article systematically covers the most exploitable vulnerability classes in staking protocols, paired with Solidity examples of both vulnerable and hardened patterns.
1. Reward Calculation Manipulation via Flash Deposit Before Snapshot
The most direct and frequently attempted exploit against reward-bearing staking contracts is the pre-snapshot flash deposit attack. The premise is mechanically simple: a reward contract calculates each participant’s share at the moment a snapshot is taken. If an attacker can deposit a large amount of capital immediately before that snapshot — and withdraw it immediately after — they dilute honest stakers’ shares while capturing a disproportionate fraction of the reward pool.
An attacker borrows a large amount of capital with a flash loan. In the same transaction, they trade aggressively, pushing conditions far away from the broader market. For a moment, the on-chain state says something is worth much more, or much less, than it really is. A vulnerable protocol then reads that manipulated state and uses it as if it were truth. In the staking context, the “state” that is manipulated is totalStaked at the snapshot block.
If updateRewards() isn’t called before a critical state-changing action (like staking more, unstaking, or claiming), the protocol may underpay or overpay users, lose track of unclaimed rewards, and allow exploiters to game the system by intentionally avoiding updates.
Vulnerable Pattern
// VULNERABLE: snapshot taken from live balance, no flash-loan guard
contract VulnerableStaking {
mapping(address => uint256) public stakedBalance;
uint256 public totalStaked;
uint256 public rewardPool;
uint256 public snapshotTotalStaked;
mapping(address => uint256) public snapshotBalance;
function deposit(uint256 amount) external {
IERC20(stakingToken).transferFrom(msg.sender, address(this), amount);
stakedBalance[msg.sender] += amount;
totalStaked += amount;
}
// ❌ Anyone can call this immediately after a large deposit
function takeSnapshot() external {
snapshotTotalStaked = totalStaked;
snapshotBalance[msg.sender] = stakedBalance[msg.sender];
}
function claimReward() external {
uint256 share = (snapshotBalance[msg.sender] * rewardPool)
/ snapshotTotalStaked;
rewardPool -= share;
IERC20(rewardToken).transfer(msg.sender, share);
}
function withdraw(uint256 amount) external {
stakedBalance[msg.sender] -= amount;
totalStaked -= amount;
IERC20(stakingToken).transfer(msg.sender, amount);
}
}
An attacker executes deposit → takeSnapshot → claimReward → withdraw in a single transaction using a flash loan. Their snapshotBalance reflects the inflated deposit, and they exit before protocol checks can intervene.
Hardened Pattern
The fix requires three cooperating defenses:
- Time-weighted average balances — reward shares must be computed over a time window, not a point-in-time snapshot.
- Per-block deposit caps or cooldowns — prevent large single-block entries from affecting reward accounting.
- Reward accrual on every state change — rewards are settled before any balance mutation.
// SECURE: time-weighted share accounting with per-block accrual
contract SecureStaking {
using SafeMath for uint256;
struct UserInfo {
uint256 amount;
uint256 rewardDebt;
uint256 lastDepositBlock;
}
mapping(address => UserInfo) public userInfo;
uint256 public totalStaked;
uint256 public accRewardPerShare; // scaled by 1e18
uint256 public lastRewardBlock;
uint256 public rewardPerBlock;
// Minimum blocks a deposit must age before it contributes to reward shares
uint256 public constant MIN_DEPOSIT_AGE_BLOCKS = 1;
modifier updatePool() {
if (block.number > lastRewardBlock && totalStaked > 0) {
uint256 blocks = block.number.sub(lastRewardBlock);
uint256 reward = blocks.mul(rewardPerBlock);
accRewardPerShare = accRewardPerShare.add(
reward.mul(1e18).div(totalStaked)
);
}
lastRewardBlock = block.number;
_;
}
function deposit(uint256 amount) external updatePool {
UserInfo storage user = userInfo[msg.sender];
// Settle any pending rewards at old balance first
if (user.amount > 0) {
uint256 pending = user.amount
.mul(accRewardPerShare)
.div(1e18)
.sub(user.rewardDebt);
if (pending > 0) _safeRewardTransfer(msg.sender, pending);
}
IERC20(stakingToken).transferFrom(msg.sender, address(this), amount);
user.amount = user.amount.add(amount);
user.lastDepositBlock = block.number;
totalStaked = totalStaked.add(amount);
user.rewardDebt = user.amount.mul(accRewardPerShare).div(1e18);
}
function withdraw(uint256 amount) external updatePool {
UserInfo storage user = userInfo[msg.sender];
require(user.amount >= amount, "insufficient balance");
// ✅ Enforce minimum deposit age to defeat flash-deposit attacks
require(
block.number > user.lastDepositBlock.add(MIN_DEPOSIT_AGE_BLOCKS),
"deposit too recent"
);
uint256 pending = user.amount
.mul(accRewardPerShare)
.div(1e18)
.sub(user.rewardDebt);
if (pending > 0) _safeRewardTransfer(msg.sender, pending);
user.amount = user.amount.sub(amount);
totalStaked = totalStaked.sub(amount);
user.rewardDebt = user.amount.mul(accRewardPerShare).div(1e18);
IERC20(stakingToken).transfer(msg.sender, amount);
}
}
The updatePool modifier ensures reward accrual is settled before any balance change — using the updateRewards() function as a modifier on all related functions is the correct pattern. The MIN_DEPOSIT_AGE_BLOCKS check ensures that same-block deposits cannot influence reward snapshots in the same transaction.
2. Withdrawal Queue Ordering Attacks
Withdrawal queues are a deliberate security primitive in staking protocols. The exit queue is a deliberate security mechanism. Without it, a malicious validator could exit the set immediately after executing a double-spend attack, before the slashing mechanism could hold them accountable. By enforcing a churn limit, the protocol ensures that stake remains at risk long enough to enforce economic accountability for validator behaviour.
At the application layer, however, custom queue implementations introduce their own attack surface. Unlike the L1 consensus queue, smart contract withdrawal queues must be audited for:
- Priority queue manipulation: Can an attacker modify their position in the queue after submission?
- FIFO bypass: Can a privileged role skip the queue entirely, leaving regular users to bear liquidity risk?
- Queue front-running: Can an attacker observe a pending large withdrawal and drain liquidity ahead of it?
- Griefing via queue inflation: Can an attacker spam small withdrawal requests to delay others?
The first desideratum is the security of the underlying protocol. If a malicious validator can corrupt the service for personal gain but then withdraw their stake before the corruption is detected, the validator is immune to punishment, and the protocol is not secure.
Vulnerable Queue Pattern
// VULNERABLE: queue position mutable after submission, no FIFO enforcement
contract VulnerableWithdrawalQueue {
struct Request {
address user;
uint256 amount;
uint256 priority; // ❌ user-settable priority
bool fulfilled;
}
Request[] public queue;
function requestWithdrawal(uint256 amount, uint256 priority) external {
// ❌ Priority is attacker-controlled — whales can always go first
queue.push(Request(msg.sender, amount, priority, false));
}
function processQueue(uint256 index) external {
Request storage r = queue[index];
require(!r.fulfilled, "already fulfilled");
// ❌ No ordering enforcement — any index can be processed first
r.fulfilled = true;
IERC20(stakingToken).transfer(r.user, r.amount);
}
}
Hardened Queue Pattern
// SECURE: strict FIFO queue with index-ordered processing
contract SecureWithdrawalQueue {
struct Request {
address user;
uint256 amount;
uint256 requestedAt; // block.number at submission
bool fulfilled;
}
Request[] public queue;
uint256 public headIndex; // next index to process
uint256 public constant MIN_WAIT_BLOCKS = 50; // anti-griefing cooldown
event WithdrawalQueued(address indexed user, uint256 amount, uint256 index);
event WithdrawalFulfilled(address indexed user, uint256 amount, uint256 index);
function requestWithdrawal(uint256 amount) external {
require(amount > 0, "zero amount");
require(userStaked[msg.sender] >= amount, "insufficient stake");
// Deduct stake immediately to prevent double-spend
userStaked[msg.sender] -= amount;
uint256 index = queue.length;
queue.push(Request({
user: msg.sender,
amount: amount,
requestedAt: block.number,
fulfilled: false
}));
emit WithdrawalQueued(msg.sender, amount, index);
}
// ✅ Only the head of the queue can be processed — strict FIFO
function processNext() external {
require(headIndex < queue.length, "empty queue");
Request storage r = queue[headIndex];
require(!r.fulfilled, "already done");
require(
block.number >= r.requestedAt + MIN_WAIT_BLOCKS,
"cooldown not elapsed"
);
r.fulfilled = true;
headIndex++;
IERC20(stakingToken).transfer(r.user, r.amount);
emit WithdrawalFulfilled(r.user, r.amount, headIndex - 1);
}
}
The key property is that queue position is assigned immutably at submission time and only the head is processable at any given time, eliminating priority manipulation entirely. Stake is debited at request-time to prevent double-spend between the request and the fulfillment.
3. Slashing Event Handling and Exchange Rate Updates
In liquid staking, when a validator is slashed, the collateral backing every LST decreases. Validators can be slashed for double-signing or prolonged downtime. In liquid staking, a slashing event reduces the collateral backing every LST holder’s tokens. Lido distributes slashing losses across the entire stETH pool, so the impact per holder is small unless many validators are slashed simultaneously.
The danger at the contract level is delayed or missing exchange rate updates after a slashing event. If the on-chain exchangeRate is not updated atomically with the slashing report, two classes of attack emerge:
- Pre-slashing extraction: An informed actor (or a validator who knows they are about to be slashed) can withdraw at the pre-slash rate before the contract rate reflects the loss.
- Post-slashing arbitrage: If the L1 slashing is reported but the LST contract’s
exchangeRatelags, arbitrageurs can buy LSTs at market prices that already discount the slash and then redeem at the protocol’s stale, higher rate.
Malicious users might attempt to prevent slashing or liquidations through transaction griefing, out-of-gas errors, or timely withdrawals. In one case, a node can avoid slashing by manipulating their deposit reserves. Nodes have to provide a deposit as insurance for their performance. The deposit size is only checked at deposit time. Thus, any reduction to this amount will not impact the node’s validity. Therefore, the node can reduce their deposit to zero and avoid slashing.
Vulnerable Slashing Handler
// VULNERABLE: slashing report and rate update are separate transactions
contract VulnerableLST {
uint256 public totalPooledEther;
uint256 public totalShares;
// ❌ This is a separate permissioned call — window for front-running
function reportSlashing(uint256 slashedAmount) external onlyOracle {
totalPooledEther -= slashedAmount;
// ❌ Exchange rate is not applied atomically — withdrawals still use old rate
}
function getExchangeRate() public view returns (uint256) {
// shares → ETH: 1 share = totalPooledEther / totalShares ETH
return totalPooledEther * 1e18 / totalShares;
}
function redeem(uint256 shares) external {
// ❌ If reportSlashing hasn't been called yet, rate is pre-slash
uint256 ethOut = shares * getExchangeRate() / 1e18;
_burnShares(msg.sender, shares);
payable(msg.sender).transfer(ethOut);
}
}
Hardened Slashing Handler
The critical property is atomic exchange rate update: the moment a slashing report is accepted, all subsequent redemptions must use the post-slash rate. Withdrawals must also be pausable during slash report periods when the true extent of the loss is still being assessed.
// SECURE: atomic slashing report with withdrawal pause and delayed resume
contract SecureLST {
uint256 public totalPooledEther;
uint256 public totalShares;
bool public withdrawalsPaused;
uint256 public slashReportBlock;
uint256 public constant SLASH_REVIEW_PERIOD = 300; // ~1 hour at 12s blocks
event SlashReported(uint256 slashedAmount, uint256 newRate);
modifier notPaused() {
require(!withdrawalsPaused, "withdrawals paused: slash under review");
_;
}
// ✅ Single atomic call: update rate AND pause withdrawals simultaneously
function reportSlashing(uint256 slashedAmount) external onlyOracle {
require(slashedAmount <= totalPooledEther, "slash exceeds pool");
// Pause withdrawals immediately to prevent pre-slash-rate redemptions
withdrawalsPaused = true;
slashReportBlock = block.number;
// ✅ Exchange rate updated atomically with the report
totalPooledEther -= slashedAmount;
emit SlashReported(slashedAmount, getExchangeRate());
}
// ✅ Resume only after the review period has elapsed
function resumeWithdrawals() external onlyGovernance {
require(
block.number >= slashReportBlock + SLASH_REVIEW_PERIOD,
"review period not elapsed"
);
withdrawalsPaused = false;
}
function getExchangeRate() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return totalPooledEther * 1e18 / totalShares;
}
function redeem(uint256 shares) external notPaused {
require(userShares[msg.sender] >= shares, "insufficient shares");
uint256 ethOut = shares * getExchangeRate() / 1e18;
_burnShares(msg.sender, shares);
(bool ok,) = payable(msg.sender).call{value: ethOut}("");
require(ok, "transfer failed");
}
}
4. Validator Key Management Risks
The off-chain component of staking protocols is as critical as the on-chain logic. Validator operations require signing keys to be online continuously, creating an inherent tension between security and availability.
Validator keys’ availability requirements are at odds with their security requirements: if an attacker compromises a validator key, they can use it to sign messages that violate the rules of the consensus protocol. Some configure their local validator client (e.g., Lighthouse or Prysm) to use encrypted keys stored in a password-protected “keystore” file. That might be OK, except that by default the password is also stored in a file, usually in a neighboring subdirectory. And the validator still holds unencrypted keys in memory when it’s running.
Real incidents have validated these concerns at scale. In the case of StakeHound, a custodial provider lost access to withdrawal keys for 38,000 ETH due to an operational error, effectively stranding the assets indefinitely. Off-chain key management is a frequent point of failure. This includes validator withdrawal keys, upgrade keys, and oracle signing keys. The BNB-chain exploit was enabled by a compromised deployer key.
The attack surface decomposes into three distinct key types:
| Key Type | Compromise Consequence | Mitigation |
|---|---|---|
| Signing/validator key | Double-signing, slashing | HSM, anti-slashing protection, policy-limited signing |
| Withdrawal key | ETH redirected to attacker | Cold storage, multi-sig, non-transferable after set |
| Admin/upgrade key | Full protocol takeover | Timelock, multi-sig, governance |
Protecting funds meant locking down the entire staking workflow: making sure that capital was safe before it was staked, that staking transactions couldn’t be altered by attackers, that validators didn’t sign slashable messages, and that withdrawals could never be diverted to malicious addresses.
At the contract layer, the withdrawal credential binding must be validated on every deposit:
// SECURE: withdrawal credential validation at deposit time
contract SecureDepositManager {
address public immutable WITHDRAWAL_CREDENTIAL_ADDRESS;
IETHDepositContract public immutable depositContract;
constructor(address _withdrawalCredential, address _depositContract) {
WITHDRAWAL_CREDENTIAL_ADDRESS = _withdrawalCredential;
depositContract = IETHDepositContract(_depositContract);
}
function stakeValidator(
bytes calldata pubkey,
bytes calldata withdrawalCredentials,
bytes calldata signature,
bytes32 depositDataRoot
) external payable onlyOperator {
require(msg.value == 32 ether, "must be exactly 32 ETH");
// ✅ Enforce that withdrawal credential matches protocol-controlled address
// First 12 bytes are the credential type prefix (0x01 = ETH1 address)
address withdrawalAddr = address(
uint160(bytes20(withdrawalCredentials[12:32]))
);
require(
withdrawalAddr == WITHDRAWAL_CREDENTIAL_ADDRESS,
"withdrawal credential mismatch"
);
depositContract.deposit{value: 32 ether}(
pubkey,
withdrawalCredentials,
signature,
depositDataRoot
);
}
}
Best practices include: minimize the number of high-privilege keys, use Hardware Security Modules (HSMs) or MPC wallets, rotate keys periodically, and establish an incident response plan for key compromise.
5. Liquid Staking Token Exchange Rate Manipulation
LSTs come in two accounting models: rebasing (balance adjusts, e.g., stETH) and reward-bearing (exchange rate adjusts, e.g., wstETH, rETH). Some derivatives adjust balances over time to reflect rewards. Others maintain a fixed balance while the token’s exchange rate changes. These differences affect accounting treatment, integration complexity, and market behavior.
The exchange rate of reward-bearing LSTs is the primary vector for manipulation. Liquid staking derivatives like stETH, wstETH, and osETH introduce hidden state changes. The exchange rate between wstETH and ETH changes over time as staking rewards accumulate. The Cork Protocol exploit involved a mismatch between how the protocol modeled wstETH’s value accrual and how it actually works. The protocol likely assumed a static 1:1 relationship that doesn’t hold. An attacker could deposit wstETH when the exchange rate is favorable, wait for it to accrue value, then withdraw more than they should be able to.
Many development teams treat all ERC-20 tokens the same way, but liquid staking derivatives work differently. This isn’t just a code problem. It’s a knowledge management problem.
There are three manipulable exchange rate vectors:
5a. Direct Pool Balance Manipulation
Attackers could use methods like flash loans to skew pool balances, devaluing or overvaluing tokens. If an LST/ETH pool is used as the authoritative exchange rate source, this rate can be manipulated within a single transaction.
// VULNERABLE: using spot pool price as exchange rate
contract VulnerableLSTVault {
IUniswapV2Pair public lstEthPair;
function getExchangeRate() public view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = lstEthPair.getReserves();
// ❌ Spot price — manipulable in a single block via flash loan
return uint256(reserve1) * 1e18 / uint256(reserve0);
}
}
// SECURE: using time-weighted average price (TWAP)
contract SecureLSTVault {
IUniswapV3Pool public lstEthPool;
uint32 public constant TWAP_WINDOW = 1800; // 30 minutes
function getExchangeRate() public view returns (uint256) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = TWAP_WINDOW;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives,) = lstEthPool.observe(secondsAgos);
int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
int24 avgTick = int24(tickDelta / int56(uint56(TWAP_WINDOW)));
// Convert tick to price
return _tickToPrice(avgTick);
}
}
5b. Oracle Signing Key Compromise
LST protocols use oracles to report rewards and update LST values (e.g., Lido’s committee, Rocket Pool’s oDAO). Failures here can mess up LST valuation. Any rate-setting oracle must have strict bounds on how much it can move the rate in a single update:
// SECURE: rate-limited oracle updates with sanity bounds
contract SecureLSTOracle {
uint256 public exchangeRate;
uint256 public lastUpdateTime;
uint256 public constant MAX_RATE_CHANGE_PER_DAY = 500; // 5% in bps
uint256 public constant MIN_UPDATE_INTERVAL = 1 hours;
function updateExchangeRate(uint256 newRate) external onlyOracle {
require(
block.timestamp >= lastUpdateTime + MIN_UPDATE_INTERVAL,
"update too frequent"
);
// ✅ Enforce maximum plausible rate movement per update
uint256 maxDelta = exchangeRate * MAX_RATE_CHANGE_PER_DAY / 10000;
require(
newRate >= exchangeRate - maxDelta &&
newRate <= exchangeRate + maxDelta,
"rate change out of bounds"
);
exchangeRate = newRate;
lastUpdateTime = block.timestamp;
}
}
6. The Risks of Staking on Behalf of Others
Liquid staking, restaking, and delegated staking all involve a principal-agent relationship: one party (the user) deposits assets, and another (the protocol, node operator, or staking provider) exercises operational control over those assets. Validators face staking penalties which can result in up to a 100% loss of their staked ETH if they fail to fulfil their responsibilities. When you stake with a service, that service will operate validator(s) on your behalf.
The staking provider handles the everyday operations on the delegated stake without actually owning the staked tokens. A staking provider cannot simply transfer away delegated tokens; however, it should be noted that the staking provider’s misbehavior may result in slashing tokens, and thus the entire staked amount is indeed at stake.
The smart contract risks specific to delegated staking are:
- Unbounded delegation: Users can delegate to any address, including malicious contracts.
- Reward redirection: Rewards for staking on behalf of a user can be diverted to an attacker-controlled address.
- Share accounting drift: If the tracking of delegated shares is incorrect, TVL and LST value can be manipulated. A flaw in the tracking of shares after forced delegations can cause the manipulation of the TVL, which in turn can be exploited to attack the token value.
// SECURE: delegated staking with reward separation and slashing accountability
contract SecureDelegatedStaking {
struct Delegation {
address owner; // ultimate principal
address operator; // who performs operations
address rewardRecipient; // where rewards go — set by owner only
uint256 stakedAmount;
uint256 rewardDebt;
}
mapping(bytes32 => Delegation) public delegations; // key: keccak256(owner, operator)
mapping(address => bool) public whitelistedOperators;
// ✅ Only the owner can set their reward recipient — prevents redirection
function setRewardRecipient(address operator, address recipient) external {
bytes32 key = _delegationKey(msg.sender, operator);
require(delegations[key].owner == msg.sender, "not owner");
require(recipient != address(0), "zero recipient");
delegations[key].rewardRecipient = recipient;
}
// ✅ Only whitelisted operators can be delegated to
function stake(address operator, uint256 amount) external {
require(whitelistedOperators[operator], "operator not whitelisted");
bytes32 key = _delegationKey(msg.sender, operator);
if (delegations[key].owner == address(0)) {
delegations[key] = Delegation({
owner: msg.sender,
operator: operator,
rewardRecipient: msg.sender, // ✅ default to owner
stakedAmount: 0,
rewardDebt: 0
});
}
IERC20(stakingToken).transferFrom(msg.sender, address(this), amount);
delegations[key].stakedAmount += amount;
}
// ✅ Slashing applied to specific delegation — operator bears the cost
function applySlash(
address owner,
address operator,
uint256 slashAmount
) external onlySlashingOracle {
bytes32 key = _delegationKey(owner, operator);
Delegation storage d = delegations[key];
require(d.stakedAmount >= slashAmount, "slash exceeds stake");
d.stakedAmount -= slashAmount;
// Slashed funds go to insurance fund, not lost silently
IERC20(stakingToken).transfer(insuranceFund, slashAmount);
}
function _delegationKey(address owner, address operator)
internal pure returns (bytes32)
{
return keccak256(abi.encodePacked(owner, operator));
}
}
Marinade on Solana structures staking so users retain “withdrawal authority” while the protocol only holds “delegation authority.” This architectural separation is the ideal model: operational authority is delegated, but exit authority always remains with the principal.
7. Invariants for Staking Protocol Solvency
Protocol invariants are properties that must hold true at all times. Violating a solvency invariant, even momentarily, typically signals either a bug or an active exploit. Auditors should instrument every state-transition function with invariant assertions, and fuzz testing frameworks (Foundry, Echidna) should validate invariant preservation across arbitrary call sequences.
Economic invariant violations let attackers mint infinite tokens, precision errors in AMM math turn tiny rounding mistakes into million-dollar exploits, and system boundary failures expose vulnerabilities that no single-component audit could catch.
The following invariants should hold in any solvency-correct staking protocol:
Invariant 1: Total Shares ↔ Total Pooled Assets
totalShares > 0 ⟹ totalPooledAssets ≥ MIN_BACKING_PER_SHARE * totalShares
Invariant 2: Sum of User Stakes ≤ Contract Balance
∑ userStaked[i] ≤ stakingToken.balanceOf(address(this))
Invariant 3: Queue Consistency
∑ queue[i].amount (for i ∈ [headIndex, queue.length)) ≤ reservedForWithdrawals
Invariant 4: Non-Decreasing Exchange Rate (absent slashing)
block.timestamp > lastRewardUpdate ⟹ exchangeRate ≥ previousExchangeRate
Invariant 5: No Free Minting
∀ deposit event: sharesMinted = deposit * totalShares / totalPooledAssets (before deposit)
// Invariant checker — call in test harness or as a view for monitoring
contract StakingInvariant
Checker is Test {
StakingPool public pool;
address[] public stakers;
constructor(StakingPool _pool, address[] memory _stakers) {
pool = _pool;
stakers = _stakers;
}
/// @notice Total staked across all users must equal pool's recorded totalStaked.
function checkTotalStaked() external view returns (bool) {
uint256 sum;
for (uint256 i = 0; i < stakers.length; i++) {
sum += pool.stakedBalanceOf(stakers[i]);
}
return sum == pool.totalStaked();
}
/// @notice Claimable rewards must never exceed the pool's reward balance.
function checkRewardSolvency() external view returns (bool) {
uint256 totalClaimable;
for (uint256 i = 0; i < stakers.length; i++) {
totalClaimable += pool.pendingRewards(stakers[i]);
}
return totalClaimable <= pool.rewardToken().balanceOf(address(pool));
}
}
Staking Security Audit Checklist
Reward accounting
-
rewardPerTokenStoredis monotonically non-decreasing - Every stake and unstake operation calls
updateRewardbefore changing balances - No user can claim rewards for a period before they staked
- Reward distribution is correct when
totalStaked == 0(no division by zero, no lost rewards)
Unbonding and withdrawal
- Unbonding delay is enforced atomically — no path bypasses the cooldown
- Unbonding entries are per-user and non-transferable
- Partial unstaking does not reset the unbonding timer for the remaining stake
- Emergency withdrawal (if present) has appropriate access control and documented trade-offs
Reward token handling
- The reward token is transferred in before any rewards are distributed — no IOU accounting
- Reward rate is bounded:
rewardRate <= rewardBalance / duration - Reward token cannot be the same as the staking token unless the consequences are explicitly analyzed
- Fee-on-transfer reward tokens are handled with balance-diff accounting
Slashing
- Slashing can only reduce a user’s staked balance, never increase it
- Slashed funds are sent to a known destination, not burned silently
- Slashing does not create a state where
totalStaked < sum(stakedBalanceOf)
Economic invariants
-
sum(stakedBalanceOf) == totalStakedat all times -
sum(pendingRewards) <= rewardToken.balanceOf(pool)at all times - Staking a dust amount then immediately unstaking cannot generate nonzero rewards