Skip to content

fix(staking): preserve fractional rewards across forced settlements - #636

Open
KrickmanC wants to merge 1 commit into
ssvlabs:mainfrom
KrickmanC:agent/preserve-fractional-staking-rewards
Open

fix(staking): preserve fractional rewards across forced settlements#636
KrickmanC wants to merge 1 commit into
ssvlabs:mainfrom
KrickmanC:agent/preserve-fractional-staking-rewards

Conversation

@KrickmanC

Copy link
Copy Markdown

fix(staking): preserve fractional rewards across forced settlements

Summary

This change preserves sub-wei staking rewards across repeated settlement events.

The current reward accounting rounds each settlement down to whole wei:

pending = (balance * (accEthPerShare - userIndex)) / PRECISION;

The user's userIndex is then advanced even when the calculated pending value is zero. Any fractional numerator below PRECISION is therefore discarded permanently.

This becomes externally triggerable because ordinary cSSV transfers settle both the sender and the recipient through onCSSVTransfer(). A third party can send a minimal cSSV amount to another account and force that account to settle without its approval.

The proposed implementation carries the fractional numerator forward between settlements so that reward outcomes remain independent of settlement frequency.

Problem

A staker's cumulative ETH rewards should depend on:

  • their cSSV balance;
  • the duration for which that balance was held;
  • changes in accEthPerShare.

They should not depend on how often another account transfers cSSV to them.

Under the existing implementation, repeated settlements can produce a different result from a single cumulative settlement.

For example, if a user earns 0.9 wei during each of ten reward periods:

Repeated settlement: floor(0.9) × 10 = 0 wei
Single settlement:   floor(0.9 × 10) = 9 wei

Because ERC-20 recipients cannot reject incoming transfers, this difference can be induced by an unrelated cSSV holder.

Root Cause

_settleWithBalance() stores only the whole-wei result of the scaled reward calculation:

pending = (bal * (idx - userIdx)) / PRECISION;

The remainder:

(bal * (idx - userIdx)) % PRECISION

is not persisted.

The function nevertheless advances:

s.userIndex[user] = idx;

As a result, the discarded fraction cannot contribute to future rewards.

Changes

1. Preserve the fractional reward numerator

A new per-user remainder mapping is appended to StorageStaking:

mapping(address => uint256) rewardRemainder;

Settlement now combines the previously stored remainder with the new scaled reward:

uint256 scaledPending = s.rewardRemainder[user];

if (bal != 0 && idx != userIdx) {
    scaledPending += bal * (idx - userIdx);
}

uint256 pending = scaledPending / PRECISION;
s.rewardRemainder[user] = scaledPending % PRECISION;

Only the whole-wei portion is added to accrued; the fractional numerator is retained for the next settlement.

2. Preserve upgradeable storage ordering

The new mapping is appended at the end of StorageStaking. Existing fields are not reordered or resized.

3. Add regression coverage

A regression test reproduces the production transfer ordering:

  1. call onCSSVTransfer() before balances change;
  2. transfer cSSV;
  3. repeat settlement after multiple accumulator increments.

The test compares:

  • a victim account settled repeatedly through unsolicited dust transfers;
  • an equivalent control account settled once after all reward periods.

Both accounts accrue the same final reward after the fix.

Behavioral Impact

The change restores the following accounting invariant:

For an equivalent balance history, cumulative rewards must not change based solely on settlement frequency.

The fix does not change:

  • the global accumulator formula;
  • cSSV minting or burning;
  • claim precision;
  • unstaking semantics;
  • staking-pool accounting;
  • the transfer-hook ordering.

It only prevents per-user fractional reward numerators from being discarded.

Security Impact

Without the remainder carry-forward, a third party can reduce another user's cumulative rewards by repeatedly forcing settlement at adversarial times.

The loss per settlement is bounded to less than one wei, and the sender does not receive the discarded value. This is therefore a low-severity accounting and griefing issue rather than a direct theft vector.

Validation

The PR includes a focused unit regression test for repeated forced settlements.

Recommended validation before merge:

npm install
npx hardhat test test/unit/SSVStaking/fractionalRewards.test.ts
npm run test:unit
npm run build

The complete upstream test suite was not executed from the current integration environment, so CI results should be treated as the authoritative validation.

Compatibility Considerations

Storage layout

The new mapping is appended to the existing staking storage struct. No existing slot assignments are modified.

Gas

Settlement performs one additional mapping read and one mapping write. This affects staking actions and cSSV transfers that trigger settlement.

Zero-balance accounts

The remainder remains associated with the account even when its cSSV balance becomes zero. This avoids destroying already-earned fractional rewards.

The maintainers may want to confirm whether sub-wei remainder should:

  • remain claimable if the user later receives cSSV again; or
  • be explicitly forfeited once the account has zero cSSV and no future accrual path.

The current implementation preserves it, which is the conservative accounting behavior.

View consistency

previewClaimableEth() should eventually include rewardRemainder[user] in the same scaled calculation so the view result remains fully aligned with settlement behavior.

That adjustment is intentionally called out for maintainer review because the current PR is focused on preventing state-level reward loss.

Requested Feedback

Please let me know whether this change is useful for the project and whether carrying the fractional numerator across transfers, unstaking operations, and zero-balance periods matches the intended staking-reward semantics.

Feedback would be particularly useful on:

  • the storage-layout approach;
  • expected handling of remainders for zero-balance accounts;
  • whether previewClaimableEth() should include the stored remainder in this PR;
  • whether the team prefers a different accounting representation for fractional rewards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant