$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

DeFi Reward Accounting Vulnerabilities: Stepwise Jumps

Updated 7 min read

Quick answer. Most DeFi reward accounting vulnerabilities come from stepwise jumps: values that change all at once in one transaction. Watch for reward compounds and donations, rate updates without a checkpoint, and balance-based vault values open to inflation attacks.

One class of DeFi reward accounting vulnerabilities has produced a couple dozen high and critical findings for us, yet almost nobody talks about it: stepwise jumps.

This guide explains what a stepwise jump is and why it is dangerous. It then walks through the three ways stepwise jumps most often show up, with example code and a real finding.

What Is a Stepwise Jump

Plot a vault's value over time. It stays flat, then at time 2 a reward compound lands and the value jumps in a single block. Afterward it stays flat again.

That sudden step is a stepwise jump. In our experience it ends in a high or critical finding about 85% of the time, and usually a medium otherwise.

The simple case is directly exploitable. A malicious user deposits right before the jump and withdraws right after.

They collect the same rewards as someone who was in the vault from time 0 to time 4. They can even do it within one block: a transaction before the compound, then one right after.

The attacker was never in the vault for a full block, yet they siphon rewards from every other depositor. That is at least a high severity finding.

The worst exploitation here is front-running and back-running the jump. (See front-running and sandwich attacks for the mechanics.)

Stepwise jumps also hide in less obvious forms. Learn to recognize one when you see it, because each one is a likely finding. Three forms come up most often.

1. Reward Compounds

Take a basic staking protocol, call it Stake Finance V1. Users stake USDC and receive a reward token that comes from donations.

It has five functions: stake, unstake, claimRewards, donate and updateRewards. Start by walking the user flows.

Walking the User Flows

  • stake pulls USDC from the caller, updates rewards, then increases the user's staked amount and the total stake.
  • unstake does the opposite: it subtracts from the user's amount and the total stake, then transfers the USDC back.
  • updateRewards uses a reward per share pattern. Pending rewards are the global reward per share, minus the value the user's stake was stamped with, times their staked amount.
  • claimRewards updates rewards first, caches the amount, zeroes it in storage, then transfers it.
  • donate pulls reward tokens from the caller and raises the reward per share by the amount divided by the total stake.

The Bug in donate

There is a high impact bug here, and it sits in donate. A single call raises the reward per share in one transaction:

function donate(uint256 amount) external {
    rewardToken.safeTransferFrom(msg.sender, address(this), amount);
    // the whole donation lands at once: a stepwise jump
    rewardPerShare += (amount * 1e18) / totalStake;
}

Chart the reward per share and a donation at time 2 is exactly the step from before. It can be arbitraged the same way.

An attacker front-runs the donation and stakes, so their stake is stamped with the old reward per share. Right after the donation, they unstake.

The unstake updates their rewards with the difference in reward per share times their amount. They can flood the contract with USDC to take most of it.

If 100 USDC was staked and the attacker stakes 1,000, they take about 90% of the donation. Then they call claimRewards.

They staked and unstaked within a single block, sandwiching the donation. Every other staker loses, because they were the rightful owners of that donation.

We would rank this high severity, and there is an argument for critical.

A Real Finding: STK-1

This simplest form comes up again and again. Guardian's Key Finance audit report includes it as STK-1, "Reward Compounds Are Sandwichable," rated High.

The report notes that no fee or lockup period applied to staking during the reward compound in updateAllRewardsForTransferReceiverAndTransferFee.

A malicious actor could buy GMXKey and stake right before that function to accrue part of the compounded rewards.

They could then claim, unstake and sell their GMXKey right after the compound, stealing rewards from other stakers.

2. Interest and Reward Rate Updates

Now a harder one. The team goes back to the drawing board and ships Stake Finance V2, with a reward rate instead of instant distribution.

Rewards accrue at a rate per second, so the reward curve is a smooth line instead of a step. It may reach the same point, but without the jump.

Stake and unstake two or three blocks apart and you only get the small difference between those points. Front-running and back-running no longer siphon anything.

Walking V2

stake is mostly the same, plus a maximum stake check. unstake is essentially unchanged.

updateRewards now uses a lastUpdatedAt timestamp. On a first stake it simply records the current time.

Otherwise it takes the time since the last update and multiplies by the reward rate, a per-share, per-second rate, and the staked amount.

There are two new owner-only functions: one sets the maximum stake and one sets the reward rate.

function _updateRewards(UserStake storage s) internal {
    if (s.lastUpdatedAt != 0) {
        uint256 elapsed = block.timestamp - s.lastUpdatedAt;
        // today's rate is applied to the whole period
        s.rewards += elapsed * rewardRate * s.amount;
    }
    s.lastUpdatedAt = block.timestamp;
}

function setRewardRate(uint256 newRate) external onlyOwner {
    rewardRate = newRate; // no checkpoint at the old rate
}

The Hidden Stepwise Jump

The jump is in setRewardRate. This one catches the vast majority of protocols with a reward or interest rate that a privileged address can update.

Chart the reward rate. It sits at 5% a year, then the owner lowers it to 3% in a single transaction. That drop is the stepwise jump.

Suppose a user stakes at time 1 and unstakes at time 5, with the rate change in between.

At stake time, lastUpdatedAt is zero, so it is set to the current timestamp. At unstake time, the four-block elapsed period is multiplied by rewardRate.

That storage variable holds only the current rate. The contract projects 3% over the whole period, though the rate was 5% for most of it.

The user loses rewards they should have earned: 5% for the first stretch, then 3%.

If the owner raises the rate to 7% instead, it is arguably worse. The contract assumes 7% from time 1 to time 5, and the user collects more than they accrued.

The root cause is that each position's pending rewards take the current rate and apply it over the entire period, whether or not the rate changed.

The Fix

Centralize the accounting. Track a global index, a reward per share as in V1, but grow it linearly over time instead of in steps. Every user accrues against that index.

Then, before changing the rate, update everyone's rewards at the current rate. Accrual up to the change happens at 5%, and from then on at 7%.

function setRewardRate(uint256 newRate) external onlyOwner {
    _updateRewardPerShare(); // accrue everyone at the old rate first
    rewardRate = newRate;
}

Many protocols skip this checkpoint. Any setRewardRate, setInterestRate or other changing rate is often a stepwise jump in disguise that breaks individual position accounting.

3. Inflation Attacks

Any stepwise jump can also become an inflation attack.

The classic case is a vault that values its tokens from balanceOf. Send a large amount of tokens to the vault and its value jumps in one step.

A depositor waiting in the mempool then has their vault tokens rounded down and loses much of their deposit.

One of the three red flags for inflation attacks is essentially a stepwise jump. The full walk-through is in our guide to the ERC-4626 inflation attack.

Stepwise Jump Checklist

Look for these three kinds of stepwise jumps in any accounting system:

  1. Reward compounds and donations. Does any single call raise a reward per share, an index or a vault's value at once? Can someone stake before it and unstake after?
  2. Rate updates. Does any reward, interest or other rate change, by an owner or otherwise? Is everyone checkpointed at the old rate before the change?
  3. Balance-based value. Can a direct token transfer move the value of a share in one step, rounding down the next depositor?

Anytime you find a stepwise jump, you have probably found a vulnerability.

How Guardian Helps

Guardian's smart contract audits build invariant suites around accounting, rounding, solvency and math formulas.

Senior researchers then attack protocol logic and privileged flows, such as rate setters, with exploit-driven review. See smart contract audits or Vanguard.

Frequently Asked Questions

What is a stepwise jump in DeFi?

A value, such as a vault's worth, a reward per share or a reward rate, that changes all at once in a single transaction instead of smoothly over time. Attackers can position around the jump, or accounting can misapply it.

How do attackers exploit a reward compound?

They stake right before the compound or donation and unstake right after, often in the same block. Their stake is stamped with the old reward per share, so they collect rewards that belonged to long-term stakers.

Why is updating a reward rate dangerous?

If positions accrue by multiplying elapsed time by the current rate, a rate change is applied retroactively to the whole period. Users then earn too little or too much. Checkpoint everyone at the old rate before changing it.

How do stepwise jumps relate to inflation attacks?

Donating tokens to a vault that values shares with balanceOf makes its value jump in one step. A pending depositor's shares then round down, which is the core of the inflation attack.

Sources

Find the stepwise jumps in your protocol

Guardian reviews reward, interest and vault accounting for the jumps attackers exploit.