60578 sc low zero boost fee for newly added levels lets users skip maturity for free and avoid paying intended vtho boost cost

Submitted on Nov 24th 2025 at 07:22:10 UTC by @unineko for Audit Comp | Vechain | Stargate Hayabusaarrow-up-right

  • Report ID: #60578

  • Report Type: Smart Contract

  • Report severity: Low

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

  • Impacts:

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

Description

Brief/Intro

In the current Stargate V3 codebase, any new NFT level added via StargateNFT.addLevel ends up with a boostPricePerBlock of 0 and there is no way to set a non-zero value for that level. As a result, holders of NFTs of newly added levels can always call boost and instantly skip the maturity period at zero VTHO cost, even though boosting is designed to require paying VTHO. This permanently underprices boosts for new levels, breaks the economic design around maturity, and allows users to keep VTHO that the protocol expects to collect as yield/fees.


Vulnerability Details

1. New Levels Never Get a Non-Zero Boost Price

Levels are added via the Levels.addLevel library function, which is called from StargateNFT.addLevel:

Key points:

  • boostPricePerBlock is a mapping in storage keyed by level ID

  • Solidity default for uint256 is 0, so every newly added level has boostPricePerBlock[levelId] == 0 by default

  • There is a library function to update this price:

However, StargateNFT.sol does not expose any public/external wrapper that calls this library for arbitrary levels. The only place where boost prices are configured is during initialization (initializeV3) for legacy levels, not for new levels added later.

Effect: For any level added after deployment via StargateNFT.addLevel, boostPricePerBlock[levelId] is locked to 0 and cannot be updated through the current public API.


2. Boost Price of 0 Implies requiredBoostAmount = 0

Boost cost is derived in the minting/boosting logic:

For a token of a newly added level:

  • $.tokens[_tokenId].levelId = newLevelId

  • $.boostPricePerBlock[newLevelId] == 0

  • Therefore requiredBoostAmount = (remainingBlocks * 0) = 0

This is true as long as the token is still under the maturity period.


3. Zero-Cost Boost Path: Checks Bypassed, Maturity Reset

Boosting on behalf of a user is implemented as:

User-facing flow (simplified):

  1. User calls Stargate.boost(tokenId) (or an equivalent function), which internally calls StargateNFT.boostOnBehalfOf(msg.sender, tokenId)

  2. For tokens of newly added levels:

    • requiredBoostAmount == 0

    • Balance/allowance checks are effectively disabled (x < 0 is false)

    • safeTransferFrom(sender, address(0), 0) succeeds

    • maturityPeriodEndBlock[tokenId] is set to the current block

Result: Any holder of a token of a newly added level can always boost for free and instantly end the maturity period.


4. Attacker/User Flow

Role assumptions:

  • LEVEL_OPERATOR_ROLE is held by protocol admins and is used to add new levels

  • Regular users have no special roles

The problematic path:

  1. Admin (with LEVEL_OPERATOR_ROLE) adds a new level via StargateNFT.addLevel(...)

  2. There is no way in the current code to set a non-zero boostPricePerBlock for that new level

  3. A regular user mints an NFT of that level via stake/stakeAndDelegate/migrate path

  4. The token starts with a non-zero maturity period

  5. The user calls boost(tokenId) (or a UI function that routes to boostOnBehalfOf)

  6. requiredBoostAmount == 0

  7. No VTHO is spent

  8. maturityPeriodEndBlock[tokenId] is set to block.number, immediately ending the maturity period

  9. The user can then delegate and start earning rewards immediately, without paying the configured VTHO boost cost that applies to existing levels

This pattern is deterministic for every token of every new level added in the future.


Impact Details

Chosen Impacts

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

Boosting for existing levels is designed to require VTHO payment proportional to the remaining maturity period. For newly added levels, this invariant silently breaks: users can get the same "boost" effect (skip the maturity lock and access rewards earlier) at zero cost.

Consequences:

  • The economic model around maturity and boosting is inconsistent across levels

  • If the docs/UI communicate that boost always costs VTHO, the implementation fails to deliver the promised fee structure

  • Early access to rewards for new-level holders is effectively subsidized by the protocol relative to existing levels

2. Theft of unclaimed yield

From the protocol's perspective, boost fees are a form of yield/fee income: users should pay VTHO in exchange for shortening their maturity lock. Because boostPricePerBlock is permanently 0 for new levels:

  • For any token of a new level, the user keeps all their VTHO while still receiving the benefit of early unlocking

  • Across all such tokens and boosts, the protocol permanently loses all expected VTHO fee income associated with boosting for those levels

  • This is a systematic and unbounded loss of protocol-side yield for all newly added levels

Scope/Severity Arguments

  • The issue affects every newly added level after deployment

  • There is no way in the current public API to configure a non-zero boost price for those levels

  • Users can repeat the free-boost pattern for every token of the affected levels

  • No user funds are stolen or frozen, but protocol economics and promised fee mechanics are clearly broken for new levels

  • Code analysis confirms this vulnerability is deterministically exploitable for all newly added levels

Given the Immunefi impact definitions, this aligns best with:

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

  • "Theft of unclaimed yield" (protocol-side VTHO boost fees that should be collected but are always bypassed for new levels)


References

Contract References

contracts/StargateNFT/StargateNFT.sol

  • addLevel(...) (calls Levels.addLevel)

contracts/StargateNFT/libraries/Levels.sol

  • addLevel(...) - sets level metadata but never sets boostPricePerBlock for new levels

  • updateLevelBoostPricePerBlock(...) - exists as a library function but has no public wrapper in StargateNFT.sol

contracts/StargateNFT/libraries/MintingLogic.sol

  • _boostAmount(...) - computes requiredBoostAmount as remainingBlocks * boostPricePerBlock[levelId]

  • boostOnBehalfOf(...) - calls _boostAmount, checks balance < requiredBoostAmount and allowance < requiredBoostAmount, then performs safeTransferFrom(sender, address(0), requiredBoostAmount) and sets maturityPeriodEndBlock[tokenId] = Clock._clock()

Proof of Concept

npx hardhat test --network hardhat test/unit/PoC/002_M1_PoC.test.ts --show-stack-traces

Was this helpful?