Cheerful Taffy Dolphin
Medium
Zero-Value Operation Bypass in Vault Update Function Enables Economic Manipulation Through Artificial Checkpoint Creation
A critical vulnerability has been identified in Perennial V2's Vault implementation that allows manipulation of core economic mechanisms through zero-value operations. The vulnerability exists in the vault's _update()
function, which manages deposits, redemptions, and claims. While the function implements checks for minimum deposits and single-sided operations, it fails to properly validate zero-value state transitions. This oversight allows an attacker to influence share price calculations, manipulate loss socialization, and force unnecessary market interactions—all without committing economic value to the system.
The severity of this issue is amplified by the vault's role as a core capital management component of the Perennial V2 protocol, directly affecting user funds and protocol stability. The primary risk lies in the manipulation of profit distribution and performance metrics, potentially impacting all vault participants through degraded price discovery and increased operational costs.
The _update()
function's validation sequence creates a critical flaw in the vault's economic safeguards by failing to properly validate zero-value operations:
function _update(Context memory context, address account, UFixed6 depositAssets, UFixed6 redeemShares, UFixed6 claimAssets) private {
if (!depositAssets.isZero() && depositAssets.lt(context.parameter.minDeposit))
revert VaultInsufficientMinimumError();
This validation structure allows zero-value operations to trigger legitimate state changes in both global and local contexts:
context.global.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.local.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.currentCheckpoint.update(depositAssets, redeemShares);
Each artificial state change cascades through the system, affecting profit calculations via the checkpoint mechanism:
(context.mark, profitShares) = nextCheckpoint.complete(mark, parameter, _checkpointAtId(timestamp));
The impact amplifies when these state changes trigger unnecessary position adjustments:
_manage(context, depositAssets, claimAmount, !depositAssets.isZero() || !redeemShares.isZero());
The primary attack vector exploits checkpoint creation through zero-value operations to manipulate share pricing and profit distribution:
context.global.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.local.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.currentCheckpoint.update(depositAssets, redeemShares);
An attacker can front-run legitimate deposits by creating artificial checkpoints that affect the profit calculation mechanism.
This manipulation affects both immediate share price calculations and long-term performance metrics through the high-water mark system. By strategically timing zero-value operations during price volatility, attackers can influence profit distribution and performance fee calculations.
Artificial state changes affect loss socialization calculations:
function _socialize(Context memory context, UFixed6 claimAssets) private pure {
return claimAssets.muldiv(
UFixed6Lib.unsafeFrom(context.totalCollateral).min(context.global.assets),
context.global.assets
);
}
By submitting zero-value operations during high volatility, attackers can force recalculation of the socialization ratio. The denominator (context.global.assets) becomes vulnerable to manipulation through pending claims, affecting loss distribution across all vault participants.
Market Position Griefing:
_manage(context, depositAssets, claimAmount, !depositAssets.isZero() || !redeemShares.isZero());
Attackers can front-run significant market updates with zero-value operations that trigger unnecessary rebalancing. This causes:
- Increased transaction costs through forced rebalancing
- Potential transaction failures from amplified slippage
- Price impact during market volatility
The vulnerability stems from a fundamental architectural flaw in the vault's state transition model. By placing economic validity checks after state transition logic, the system fails to distinguish between meaningful and artificial state changes. This architectural weakness transforms zero-value operations from administrative actions into vectors for economic exploitation.
These design flaws allow an attacker to systematically degrade the vault's economic integrity through targeted zero-value operations, affecting share price discovery, loss socialization, and market position management.
This fix prevents artificial checkpoint creation and ensures all operations have economic substance before affecting vault state.
function _update(
Context memory context,
address account,
UFixed6 depositAssets,
UFixed6 redeemShares,
UFixed6 claimAssets
) private {
// Magic value handling
if (claimAssets.eq(UFixed6Lib.MAX)) claimAssets = context.local.assets;
if (redeemShares.eq(UFixed6Lib.MAX)) redeemShares = context.local.shares;
// Operator validation
if (msg.sender != account && !IVaultFactory(address(factory())).operators(account, msg.sender))
revert VaultNotOperatorError();
// Validate meaningful operation first
if (depositAssets.isZero() && redeemShares.isZero() && claimAssets.isZero())
revert VaultNoOperationError();
// Single-sided operation check
if (!depositAssets.add(redeemShares).add(claimAssets).eq(depositAssets.max(redeemShares).max(claimAssets)))
revert VaultNotSingleSidedError();
// Economic validation for non-zero operations
if (depositAssets.gt(UFixed6Lib.ZERO)) {
if (depositAssets.gt(_maxDeposit(context)))
revert VaultDepositLimitExceededError();
if (depositAssets.lt(context.parameter.minDeposit))
revert VaultInsufficientMinimumError();
}
if (redeemShares.gt(UFixed6Lib.ZERO)) {
if (context.latestCheckpoint.toAssets(redeemShares).lt(context.parameter.minDeposit))
revert VaultInsufficientMinimumError();
}
if (context.local.current != context.local.latest)
revert VaultExistingOrderError();
// Process operations
UFixed6 claimAmount = _socialize(context, claimAssets);
// Update positions
context.global.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.local.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.currentCheckpoint.update(depositAssets, redeemShares);
// Manage assets
asset.pull(msg.sender, UFixed18Lib.from(depositAssets));
_manage(context, depositAssets, claimAmount, !depositAssets.isZero() || !redeemShares.isZero());
asset.push(msg.sender, UFixed18Lib.from(claimAmount));
emit Updated(msg.sender, account, context.currentId, depositAssets, redeemShares, claimAssets);
}
error VaultNoOperationError();