The Stargate contract contain a logic error in the unstake() that cause a permanent lock of user funds when a validator exit after the user has already requested a delegation exit.
The protocol track a validator stake to calculate rewards. This stake must be decremented exactly once when a user stop delegating. However, the unstake() attempt to decrement this stake based on the Validator Status without verifying if the user has already decremented their stake through requestDelegationExit() call.
The Logical Flaw:
As unstake() is the only mechanism to retrieve the staked principal (VET) and the transaction unconditionally revert due to the underflow, the user fund become permanently trapped in the contract.
Steps:
Modify the unstake() to check if the delegation exit has already been requested (and then the stake already removed) before attempting to decrement the stake. This can be determined by checking if delegation.endPeriod is set to a value other than type(uint32).max.
function unstake(uint256 _tokenId) ... {
// ... setup code ...
(, , , , uint8 currentValidatorStatus, ) = $.protocolStakerContract.getValidation(
delegation.validator
);
+ // Fix: Check if exit was already requested (stake already removed)
+ bool exitAlreadyRequested = delegation.endPeriod != type(uint32).max;
// Only decrement if validator is EXITED and we haven't already done so
if (
- currentValidatorStatus == VALIDATOR_STATUS_EXITED ||
- delegation.status == DelegationStatus.PENDING
+ (currentValidatorStatus == VALIDATOR_STATUS_EXITED || delegation.status == DelegationStatus.PENDING) &&
+ !exitAlreadyRequested
) {
// existing decrement logic
_updatePeriodEffectiveStake(..., false);
}
// ...
}
Add to packages/contracts/test/unit/Stargate/Delegation.test.ts
it("Unstake Revert if Validator Exit after User Request", async () => {
// Setup: Stake using Mocks
const levelSpec = await stargateNFTMock.getLevel(LEVEL_ID);
await stargateContract.connect(user).stake(LEVEL_ID, { value: levelSpec.vetAmountRequiredToStake });
const tokenId = await stargateNFTMock.getCurrentTokenId();
// Delegate to Validator
await stargateContract.connect(user).delegate(tokenId, validator.address);
// Fast-forward to make delegation active
// I manually set the mock state to simulate time passing
await protocolStakerMock.helper__setValidationCompletedPeriods(validator.address, 2);
// Verify status is active
expect(await stargateContract.getDelegationStatus(tokenId)).to.equal(2);
// User request exit
// This decrement the stake for the validator ONCE.
await stargateContract.connect(user).requestDelegationExit(tokenId);
// Validator Exit (slashed/jailed/stopped)
// force the mock validator status to EXITED (Enum value 3)
await protocolStakerMock.helper__setValidatorStatus(validator.address, 3);
// User attempt to unstake to get their VET back.
// The contract see the validator is EXITED and attempt to decrement stake again.
// Since stake was already removed in step 3 (0 - Amount) cause Underflow.
console.log("\n๐ Attempting Unstake (Expecting Revert due to Double Decrement)...");
await expect(
stargateContract.connect(user).unstake(tokenId)
).to.be.reverted; // The revert confirm the bug
console.log("๐ CONFIRMED: User fund are permanently locked due to underflow.");
});
๐ Attempting Unstake (Expecting Revert due to Double Decrement)...
๐ CONFIRMED: User fund are permanently locked due to underflow.
โ Unstake Revert if Validator Exit after User Request (69ms)
1 passing (13s)