60079 sc low critical historical state corruption via stale checkpoints leads to permanent loss of future yield

Submitted on Nov 18th 2025 at 09:53:06 UTC by @kind0dev for Audit Comp | Vechain | Stargate Hayabusaarrow-up-right

  • Report ID: #60079

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/tree/main/packages/contracts/contracts/Stargate.sol

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

    • Theft of unclaimed yield

Description

Brief/Intro

A critical state corruption vulnerability exists in the unstake() function of the Stargate.sol contract. When a user unstakes from a delegation that was forcibly EXITED (due to the validator failing), the cleanup logic incorrectly writes a new historical checkpoint instead of correcting the last scheduled one. This leaves a permanent, incorrect "ghost stake" entry in the historical ledger for a reward period that never occurred for that validator. This corruption makes any future reward reconciliation impossible and will lead to an irrecoverable loss and unfair distribution of yield for other users who were staked in that same period.

1

Vulnerability Details — Overview

The vulnerability arises from the interaction between the contract's state logic and the append-only nature of the OpenZeppelin Checkpoints library, which is used to store the historical delegatorsEffectiveStake for each validator.

2

The Append-Only Ledger

The Checkpoints.Trace struct is essentially an append-only log. New entries can only be pushed with a key (period number) strictly greater than the last. There is no mechanism to edit or remove a historical entry once a newer entry has been added.

3

Scheduling Future Stake

When a user delegates, the _delegate() function calls _updatePeriodEffectiveStake() to schedule an increase in the validator's stake for a future period, specifically completedPeriods + 2. This writes a checkpoint that is not yet active. For example, if a validator has completed Period 9, a new delegation will write a checkpoint for Period 11.

4

The Forced Exit Scenario

A validator can be forcefully removed from the network by the protocol (e.g., for being offline). When this happens:

  • The validator's status in IProtocolStaker becomes EXITED.

  • Its completedPeriods counter freezes permanently. In the example, it freezes at 10 after completing Period 10.

  • Any delegations to this validator now implicitly have a status of EXITED as determined by the _getDelegationStatus() function. This occurs without the user calling requestDelegationExit().

5

The Flawed Cleanup in unstake()

When a user from the failed validator calls unstake(), the following flawed sequence occurs:

  • The function identifies the delegation as EXITED and proceeds.

  • It determines it needs to clean up the user's stake. It calculates the period to update as completedPeriods + 2 (which is 10 + 2 = 12 in the example).

  • It calls _updatePeriodEffectiveStake(..., period=12, isIncrease=false).

  • Inside this function, it reads the previous stake value by looking up the last checkpoint (the one for Period 11) and subtracts the user's stake, calculating a new value of 0.

  • It then pushes a new checkpoint for Period 12 with a value of 0.

The historical record for the validator is now permanently corrupted: Checkpoints array: [{key: 11, value: 1,000,000}, {key: 12, value: 0}]

The "ghost stake" entry at Period 11 is now immutable and incorrect. It represents a stake on the validator for a period that the validator never completed.

Impact Details

This vulnerability has critical, long-term financial consequences for the protocol and its users. While it does not cause an immediate, direct theft of principal, it corrupts the ledger in a way that guarantees future financial loss.

  • Permanent Loss of User Yield: The primary impact is the theft of unclaimed yield. In any future scenario that requires re-calculating historical rewards (such as a contract upgrade, a manual reconciliation event, or fixing an unrelated bug), the rewards for the "ghost" period (Period 11) will be calculated against an inflated delegatorsEffectiveStake. The portion of rewards allocated to the non-existent ghost stake will be permanently lost and can never be claimed by the legitimate delegators of that period. This aligns with the High severity impact "Theft of unclaimed yield."

  • Protocol Insolvency for Historical Periods: The protocol becomes unable to fulfill its promise of fair reward distribution for the corrupted historical period. It creates a deficit where the rewards earned by the validator for that period cannot be fully and correctly distributed to the actual stakeholders of that period.

  • Corruption of Ecosystem Data: The on-chain historical record is the source of truth for all off-chain services. This bug poisons that data, leading to incorrect calculations for user dashboards, tax reporting software, and validator reputation systems that rely on accurate historical APY data.

The "it's in the past" argument is not valid for a blockchain protocol. The history is the state, and a permanent error in the historical financial ledger is a fundamental flaw that will inevitably cause financial harm when that history is referenced.

References

  • Vulnerable Function: unstake() in Stargate.sol https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/main/packages/contracts/contracts/Stargate.sol#L231

  • Flawed Cleanup Logic: The call to _updatePeriodEffectiveStake() within unstake() for EXITED validators. https://github.com/immunefi-team/audit-comp-vechain-stargate-hayabusa/blob/main/packages/contracts/contracts/Stargate.sol#L276

Proof of Concept

The following Hardhat unit test provides an executable demonstration of the vulnerability.

How to Run: Save the code below as a test file (e.g., test/unit/poc.test.ts) and run the following command from the repository root:

yarn dotenv -v VITE_APP_ENV=local -v TEST_LOGS=1 hardhat test --network hardhat test/unit/poc.test.ts

1

PoC Step 1 — Stake and schedule checkpoint

Alice stakes and delegates to ValidatorX in Period 9. This schedules a checkpoint for Period 11.

2

PoC Step 2 — Validator completes next period and is forcefully exited

ValidatorX completes Period 10 and is then forcefully set to status EXITED. completedPeriods is frozen at 10.

3

PoC Step 3 — Alice unstakes

Alice calls unstake() in a later period. The unstake() cleanup logic writes a new checkpoint for Period 12 (the frozen completedPeriods + 2) instead of correcting the previously scheduled Period 11 checkpoint.

4

PoC Step 4 — Verification

Verify that the historical checkpoint for Period 11 remains as the original scheduled stake (a "ghost stake") and that Period 12 is set to 0. This demonstrates permanent state corruption.

Test Code

chevron-rightExpected Outputhashtag

Was this helpful?