57685 sc critical vulnerabilities in the design of the token s staking mechanism resulted in financial harm to users involved in transfer related operations

Submitted on Oct 28th 2025 at 06:47:47 UTC by @queen for Audit Comp | Belongarrow-up-right

  • Report ID: #57685

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/Staking.sol

  • Impacts: Vulnerabilities in the design of the token's staking mechanism resulted in financial harm to users involved in transfer-related operations.

Description

Brief/Intro

The Staking.sol ERC4626 vault mints and allows transfer of ERC4626 shares while lock metadata (per-deposit stakes) is tracked separately by address. When a user transfers unlocked shares to another address, the recipient receives ERC4626 shares but no corresponding lock entries. As a result the recipient cannot perform normal withdrawals (which require consuming unlocked stakes) and is forced to use the emergency withdrawal path, incurring a penalty. This leads to direct economic loss and breaks expected unlock semantics.

Vulnerability Details

  • The contract stores lock state per address:

mapping(address staker => Stake[] times) public stakes;
  • On deposit, the contract mints ERC4626 shares and appends a lock entry for the recipient:

function _deposit(address by, address to, uint256 assets, uint256 shares) internal override {
    super._deposit(by, to, assets, shares);
    // lock freshly minted shares
    stakes[to].push(Stake({shares: shares, timestamp: block.timestamp}));
}
  • On normal withdrawal, the contract consumes “unlocked shares” from stakes[owner] and reverts if locks aren’t satisfied:

  • If a user transfers ERC4626 shares (permitted by ERC4626/solady ERC20 implementation), the contract does not migrate the corresponding stakes[from] entries to stakes[to]. The recipient thus owns shares without any lock entries. When they attempt a standard withdrawal, _consumeUnlockedSharesOrRevert(to, ...) finds no unlocked stake entries and reverts. The only available path becomes the emergency withdrawal, which always applies a penalty:

  • This occurs even if the transferred shares were already fully unlocked at the sender address. Unlock state is tied to stakes[owner] and does not follow share transfers.

Root cause:

  • ERC4626 shares are transferable.

  • No transfer hook overrides to block transfers or migrate stakes on transfer/transferFrom.

  • Withdrawal logic requires lock metadata on the current owner, not on the original depositor.

Impact Details

  • Forced penalty loss: Users who wait out the minimum stake period and then transfer their unlocked shares to another wallet will be unable to perform a standard withdrawal and will incur the emergency withdrawal penalty (default 10%), causing direct economic loss.

  • UX/DoS of normal withdrawal: Recipients of transferred shares are effectively denied the standard withdraw path due to missing lock entries, despite holding shares that semantically should be unlocked.

  • Griefing / integration risk: Any workflow that moves shares between wallets (custodial moves, account abstractions, migrations) may unintentionally convert “unlocked” assets into “penalized-only” assets.

  • Severity: High for affected users (loss proportional to position size × penalty). The issue undermines the advertised semantics of time-locked staking that unlocks after min period.

Concrete scenario

1

Depositor stakes and waits

User A deposits LONG and waits past minStakePeriod (all shares matured).

2

Transfer of unlocked shares

User A transfers all shares to User B (e.g., moving to a hardware wallet).

3

Normal withdrawal fails for recipient

User B attempts to withdraw; _consumeUnlockedSharesOrRevert(B, …) finds no unlocked entries (there are none), so it reverts.

4

Emergency path imposes penalty

User B must use emergency withdrawal and loses penaltyPercentage of assets.

References (code pointers)

  • Lock state tracking and deposit hook:

  • Normal withdraw requires unlocked stakes; otherwise revert:

  • Emergency withdrawal applies penalty:

Suggested remediations

circle-info

Do not add any behavior or code beyond what's suggested here — these are remediation patterns only.

  • Option A — Make shares non-transferable: override ERC20 transfer hooks (transfer / transferFrom) to revert, preserving simple per-address lock semantics.

  • Option B — Migrate stake metadata on transfers: implement logic in transfer hooks to split/merge/copy stake entries so that lock timestamps follow shares. This must correctly handle partial transfers and gas implications (stake array management, consolidation).

  • Option C — Change withdrawal semantics: allow consumption of unlocked shares based on on-chain share ownership and original deposit timestamps (more complex; requires design to prevent circumvention).

  • Consider documenting the exact semantics for integrators and users (e.g., "shares are transferable but lock metadata is per-address" is surprising and dangerous).

Proof of Concept

  • Run with Foundry:

    • Place as test/StakingTransferPoC.t.sol

    • forge test -m test_TransferUnlockedSharesBreaksWithdrawForRecipient

Was this helpful?