Dazzling Pastel Gibbon
High
When the contract calls _update(), which then invokes _checkpoint(context), it correctly generates an updated context.currentCheckpoint and stores it in _checkpoints[newId]. However, it never writes this new checkpoint’s ID back to the global state (context.global.current).
As a result, the subsequent settlement logic (_settle())—which relies on iterating through context.global.current and context.global.latest—fails to detect the newly created checkpoint. This ultimately isolates the new checkpoint, making it impossible to be processed properly.
https://github.com/sherlock-audit/2025-01-perennial-v2-4-update/blob/2381b47e69b17fe892b1d1d0f467c358cf950143/perennial-v2/packages/vault/contracts/Vault.sol#L297 Detailed Explanation of the Bug
- A new checkpoint is created but context.global.current is not updated
function _checkpoint(Context memory context) private view {
// 1) Fetch the "current" context.global.current
context.currentId = context.global.current;
context.currentCheckpoint = _checkpoints[context.currentId].read();
// 2) If now > current checkpoint.timestamp, increment currentId
// and modify the new checkpoint in memory
if (context.currentTimestamp > context.currentCheckpoint.timestamp) {
context.currentId++;
context.currentCheckpoint.next(context.currentTimestamp, context.global);
}
}
• Here, context.currentId may be incremented, but this change only happens locally.
• The new context.currentId is never written back to context.global.current.
- The new checkpoint is stored, but context.global.current remains unchanged
function _saveContext(Context memory context, address account) private {
if (account != address(0)) _accounts[account].store(context.local);
_accounts[address(0)].store(context.global);
// The new checkpoint is stored in _checkpoints
_checkpoints[context.currentId].store(context.currentCheckpoint);
// However, context.currentId is NOT written back to context.global.current
// This means the global "current" ID does not advance.
}
• The contract correctly saves the new checkpoint in _checkpoints[...].
• However, it never updates context.global.current, meaning the global state does not recognize that a new checkpoint was created.
- _settle() relies on context.global.current > context.global.latest to process new checkpoints
while (
context.global.current > context.global.latest &&
...
) {
// Only then does it proceed to call nextCheckpoint.complete(...)
}
• _settle() only processes checkpoints that context.global.current acknowledges.
• Since context.global.current was never updated, _settle() never sees the newly created checkpoint.
• As a result, the settlement logic skips the new checkpoint, leaving it orphaned and preventing it from being finalized.
Fixing the Issue
A standard fix is to update _checkpoint() or _saveContext() so that the incremented context.currentId is written back to context.global.current. This ensures that the global state is aware of the newly created checkpoint.
Fixed Version of _checkpoint() and _saveContext()
function _checkpoint(Context memory context) private view {
context.currentId = context.global.current;
context.currentCheckpoint = _checkpoints[context.currentId].read();
if (context.currentTimestamp > context.currentCheckpoint.timestamp) {
context.currentId++;
context.currentCheckpoint.next(context.currentTimestamp, context.global);
}
}
function _saveContext(Context memory context, address account) private {
if (account != address(0)) _accounts[account].store(context.local);
// FIX: Write context.currentId back to context.global.current
context.global.current = context.currentId;
_accounts[address(0)].store(context.global);
_checkpoints[context.currentId].store(context.currentCheckpoint);
}
Why This Fix Works 1. The new checkpoint’s ID is now stored in context.global.current, making it globally recognized. 2. _settle() can now detect the new checkpoint and process it correctly. 3. The contract no longer leaves checkpoints orphaned and ensures proper settlement