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 | Belong
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 tostakes[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
stakesontransfer/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
References (code pointers)
Lock state tracking and deposit hook:
Normal withdraw requires unlocked stakes; otherwise revert:
Emergency withdrawal applies penalty:
Suggested remediations
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.solforge test -m test_TransferUnlockedSharesBreaksWithdrawForRecipient
Was this helpful?