The Stargate protocol uses a checkpoint-based system to track effective stake per period. When a user delegates, effective stake is added for period completedPeriods + 2. When a user requests exit, the effective stake should be reduced for the appropriate period.
The vulnerability occurs in the following scenario:
completedPeriods + 2requestDelegationExit reduces effective stake for period completedPeriods + 2 (at the time of exit request)VALIDATOR_STATUS_EXITED, freezing oldCompletedPeriodsThe issue stems from two separate code paths that both reduce effective stake without tracking whether a reduction has already occurred:
First Reduction (requestDelegationExit, line 568):
completedPeriods + 2 when exit is requestedupperLookup() which finds the nearest checkpoint at or before the target periodSecond Reduction (_delegate, lines 398-413, and unstake, lines 264-283):
VALIDATOR_STATUS_EXITEDoldCompletedPeriods + 2oldCompletedPeriods value from when the validator exitedThe problem is that:
oldCompletedPeriods which may be different from the completedPeriods used in the first reductionupperLookup() returns a value that's already been reduced (or is 0), subtracting the effective stake again causes underflow1. Delegate at completedPeriods = 5
→ Stake added for period 7
2. Request exit at completedPeriods = 6
→ First reduction: Attempts to reduce for period 8
→ upperLookup(8) finds period 7's checkpoint (value = tokenEffectiveStake)
→ Creates period 8 checkpoint with value 0
3. Advance period, delegation becomes EXITED
4. Validator exits
→ oldCompletedPeriods frozen at 7
5. User attempts to switch/unstake
→ Second reduction: Attempts to reduce for period 9 (7 + 2)
→ upperLookup(9) finds period 8's checkpoint (value = 0)
→ Calculation: 0 - tokenEffectiveStake = UNDERFLOW
→ Transaction reverts
→ Funds permanently locked
delegate() and unstake() revert, leaving no path to recover fundsrequestDelegationExit:// File: packages/contracts/contracts/Stargate.sol
// Lines: 560-568
// decrease the effective stake
// Get the latest completed period of the validator
(, , , uint32 completedPeriods) = $.protocolStakerContract.getValidationPeriodDetails(
delegation.validator
);
(, uint32 exitBlock) = $.protocolStakerContract.getDelegationPeriodDetails(delegationId);
// decrease the effective stake
_updatePeriodEffectiveStake($, delegation.validator, _tokenId, completedPeriods + 2, false);
_delegate:// File: packages/contracts/contracts/Stargate.sol
// Lines: 398-413
if (
currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
status == DelegationStatus.PENDING
) {
// get the completed periods of the previous validator
(, , , uint32 oldCompletedPeriods) = $
.protocolStakerContract
.getValidationPeriodDetails(currentValidator);
// decrease the effective stake of the previous validator
_updatePeriodEffectiveStake(
$,
currentValidator,
_tokenId,
oldCompletedPeriods + 2,
false // decrease
);
}
unstake:// File: packages/contracts/contracts/Stargate.sol
// Lines: 264-283
if (
currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
delegation.status == DelegationStatus.PENDING
) {
// get the completed periods of the previous validator
(, , , uint32 oldCompletedPeriods) = $
.protocolStakerContract
.getValidationPeriodDetails(delegation.validator);
// decrease the effective stake of the previous validator
_updatePeriodEffectiveStake(
$,
delegation.validator,
_tokenId,
oldCompletedPeriods + 2,
false // decrease
);
}
_updatePeriodEffectiveStake:// File: packages/contracts/contracts/Stargate.sol
// Lines: 1003-1009
// get the current effective stake
uint256 currentValue = $.delegatorsEffectiveStake[_validator].upperLookup(_period);
// calculate the updated effective stake
uint256 updatedValue = _isIncrease
? currentValue + effectiveStake
: currentValue - effectiveStake; // <-- UNDERFLOW OCCURS HERE
The following test demonstrates the vulnerability:
// File: test/unit/Stargate/Rewards.test.ts
// PoC: Finding 2 - Double Reduction of Effective Stake Leading to Permanent Fund Lockup
it("PoC: Demonstrates double reduction causes underflow and permanent fund lockup", async () => {
// Setup: Stake and delegate token
const levelSpec = await stargateNFTMock.getLevel(LEVEL_ID);
tx = await stargateContract.connect(user).stake(LEVEL_ID, {
value: levelSpec.vetAmountRequiredToStake,
});
await tx.wait();
const tokenId = await stargateNFTMock.getCurrentTokenId();
const tokenEffectiveStake = await stargateContract.getEffectiveStake(tokenId);
// Step 1: Delegate at completedPeriods = 5 (stake recorded for period 7)
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
validator.address,
5n
);
await tx.wait();
tx = await stargateContract.connect(user).delegate(tokenId, validator.address);
await tx.wait();
// Verify stake was added for period 7
const effectiveStakePeriod7 = await stargateContract.getDelegatorsEffectiveStake(
validator.address,
7
);
expect(effectiveStakePeriod7).to.equal(tokenEffectiveStake);
// Step 2: Advance to period 7 and request exit (completedPeriods = 6)
// FIRST REDUCTION: requestDelegationExit reduces for period 6 + 2 = 8
// upperLookup(8) finds period 7's checkpoint, creates period 8 checkpoint with value 0
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
validator.address,
6n
);
await tx.wait();
const delegationStatus1 = await stargateContract.getDelegationStatus(tokenId);
expect(delegationStatus1).to.equal(DELEGATION_STATUS_ACTIVE);
tx = await stargateContract.connect(user).requestDelegationExit(tokenId);
await tx.wait();
// Verify first reduction: period 8 checkpoint created with value 0
const effectiveStakePeriod8AfterFirstReduction = await stargateContract.getDelegatorsEffectiveStake(
validator.address,
8
);
expect(effectiveStakePeriod8AfterFirstReduction).to.equal(0n);
// Step 3: Advance period so delegation becomes EXITED
tx = await protocolStakerMock.helper__setValidationCompletedPeriods(
validator.address,
7n
);
await tx.wait();
const delegationStatus2 = await stargateContract.getDelegationStatus(tokenId);
expect(delegationStatus2).to.equal(DELEGATION_STATUS_EXITED);
// Step 4: Validator exits (freezes oldCompletedPeriods at 7)
tx = await protocolStakerMock.helper__setValidatorStatus(
validator.address,
VALIDATOR_STATUS_EXITED
);
await tx.wait();
const validatorInfo = await protocolStakerMock.getValidation(validator.address);
expect(validatorInfo._status).to.equal(VALIDATOR_STATUS_EXITED);
const validationPeriodDetails = await protocolStakerMock.getValidationPeriodDetails(validator.address);
expect(validationPeriodDetails.completedPeriods).to.equal(7n);
// Step 5: Attempt to switch validators triggers SECOND REDUCTION
// Code tries to reduce for period: oldCompletedPeriods + 2 = 7 + 2 = 9
// upperLookup(9) returns period 8's value (0), causing underflow: 0 - tokenEffectiveStake
const effectiveStakePeriod9Before = await stargateContract.getDelegatorsEffectiveStake(
validator.address,
9
);
expect(effectiveStakePeriod9Before).to.equal(0n);
// This reverts due to arithmetic underflow (Solidity 0.8.20 built-in protection)
await expect(
stargateContract.connect(user).delegate(tokenId, otherValidator.address)
).to.be.reverted;
// Step 6: Verify unstake also fails (same double reduction logic)
await expect(
stargateContract.connect(user).unstake(tokenId)
).to.be.reverted;
// Step 7: Verify checkpoint state
const finalEffectiveStakePeriod7 = await stargateContract.getDelegatorsEffectiveStake(
validator.address,
7
);
const finalEffectiveStakePeriod8 = await stargateContract.getDelegatorsEffectiveStake(
validator.address,
8
);
const effectiveStakePeriod9 = await stargateContract.getDelegatorsEffectiveStake(
validator.address,
9
);
// Period 7 still has stake (Finding 1 bug), period 8 has 0, period 9 lookup returns 0
expect(finalEffectiveStakePeriod7).to.equal(tokenEffectiveStake);
expect(finalEffectiveStakePeriod8).to.equal(0n);
expect(effectiveStakePeriod9).to.equal(0n);
// Final verification: Delegation still exists, proving funds are locked
// User cannot recover funds because both delegate() and unstake() revert
const delegationId = await stargateContract.getDelegationIdOfToken(tokenId);
expect(delegationId).to.be.greaterThan(0n);
const finalDelegationStatus = await stargateContract.getDelegationStatus(tokenId);
expect(finalDelegationStatus).to.equal(DELEGATION_STATUS_EXITED);
});