Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contracts/libraries/storage/SSVStorageStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ struct StorageStaking {
uint16 quorumBps;
/// @notice The mapping of address to their unstake requests
mapping(address => UnstakeRequest[]) withdrawalRequests;
/// @notice Fractional reward numerator carried between settlements (always less than PRECISION)
mapping(address => uint256) rewardRemainder;
}

library SSVStorageStaking {
Expand Down
14 changes: 9 additions & 5 deletions contracts/modules/SSVStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,16 @@ contract SSVStaking is ISSVStaking, SSVReentrancyGuard {
uint256 idx = s.accEthPerShare;
uint256 userIdx = s.userIndex[user];

uint256 pending;
uint256 scaledPending = s.rewardRemainder[user];
if (bal != 0 && idx != userIdx) {
pending = (bal * (idx - userIdx)) / PRECISION;
if (pending != 0) {
s.accrued[user] += pending;
}
scaledPending += bal * (idx - userIdx);
}

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

if (pending != 0) {
s.accrued[user] += pending;
}

s.userIndex[user] = idx;
Expand Down
115 changes: 115 additions & 0 deletions test/unit/SSVStaking/fractionalRewards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { expect } from "chai";
import type { NetworkConnection } from "hardhat/types/network";
import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types";

import { defaultStakingFixture } from "../../helpers/fixture-presets.ts";
import type { NetworkHelpersType } from "../../common/types.ts";
import { setupTestContext } from "../../common/helpers.ts";

const PRECISION = 10n ** 18n;
const ETH_DEDUCTED_DIGITS = 100_000n;
const TOTAL_CSSV_SUPPLY = 10n ** 24n;
const ACCOUNT_BALANCE = 1_000_000_000n;
const FEE_STEP_PACKED = 9_000_000_000n;
const ROUNDS = 10;

describe("SSVStaking fractional reward settlement", () => {
let connection: NetworkConnection<"generic">;
let networkHelpers: NetworkHelpersType;

let attacker: HardhatEthersSigner;
let victim: HardhatEthersSigner;
let control: HardhatEthersSigner;

before(async function () {
({
connection,
networkHelpers,
signers: [attacker, victim, control],
} = await setupTestContext());
});

const deployFixture = async () => defaultStakingFixture(connection);

async function impersonate(address: string) {
await connection.ethers.provider.send("hardhat_impersonateAccount", [
address,
]);
await connection.ethers.provider.send("hardhat_setBalance", [
address,
"0x1000000000000000000",
]);
return connection.ethers.getSigner(address);
}

async function transferWithProductionHook(
staking: any,
cssvToken: any,
cssvSigner: any,
from: HardhatEthersSigner,
to: string,
amount: bigint,
) {
await staking
.connect(cssvSigner)
.onCSSVTransfer(from.address, to, amount);
await cssvToken.connect(from).transfer(to, amount);
}

it("preserves fractional rewards across unsolicited dust transfers", async function () {
const { staking, cssvToken } =
await networkHelpers.loadFixture(deployFixture);

const cssvSigner = await impersonate(await cssvToken.getAddress());

await staking.mockSetDaoTotalEthVUnits(0n);
await staking.mockSetEthNetworkFee(0n);
await staking.mockSetStakingEthPoolBalance(0n);
await staking.mockSetEthDaoBalance(0n);

await cssvToken.mint(victim.address, ACCOUNT_BALANCE);
await cssvToken.mint(control.address, ACCOUNT_BALANCE);
await cssvToken.mint(
attacker.address,
TOTAL_CSSV_SUPPLY - (2n * ACCOUNT_BALANCE),
);

const expectedIndexStep =
(FEE_STEP_PACKED * ETH_DEDUCTED_DIGITS * PRECISION) /
TOTAL_CSSV_SUPPLY;
expect(expectedIndexStep).to.equal(900_000_000n);

for (let round = 1; round <= ROUNDS; round++) {
await staking.mockSetEthDaoBalance(
FEE_STEP_PACKED * BigInt(round),
);

await transferWithProductionHook(
staking,
cssvToken,
cssvSigner,
attacker,
victim.address,
1n,
);
}

await transferWithProductionHook(
staking,
cssvToken,
cssvSigner,
attacker,
control.address,
1n,
);

expect(await staking.getAccEthPerShare()).to.equal(
expectedIndexStep * BigInt(ROUNDS),
);
expect(await staking.getUserAccrued(victim.address)).to.equal(9n);
expect(await staking.getUserAccrued(control.address)).to.equal(9n);
expect(await cssvToken.balanceOf(victim.address)).to.equal(
ACCOUNT_BALANCE + BigInt(ROUNDS),
);
});
});