Parallel analysis lane
In the repo /home/akitav2/projects/4626, investigate the impairment side-pocket contracts for three audit findings. Read:
- contracts/vault/modules/CreatorOVaultCoreModule.sol — specifically clearImpairmentTrip (~line 885), clearImpairmentRootAfterChallenge (~932), mintImpairmentClaim (~964), notifyImpairmentRecovery (~985), claimImpairmentRecovery (~1002), the impairment epoch struct, and proposeImpairmentRoot.
- contracts/vault/CreatorORecoveryEscrow.sol (full file, ~small)
- contracts/vault/CreatorOImpairmentClaims.sol (find it; the mintFromVault function)
- Any existing Foundry tests covering impairment (search test/ for Impairment).
Report back:
- C-2: Confirm mintImpairmentClaim / claims mintFromVault never enforce cumulative minted <= epoch.totalClaimSupply, and confirm CreatorORecoveryEscrow.claimRecovery never checks claimedByEpochAsset <= recoveredByEpochAsset before safeTransfer. Quote the relevant lines with line numbers. Note what state is available to add the cap (does claims contract track total minted per epoch? does escrow track recoveredByEpochAsset per epoch+asset?).
- C-3: Confirm clearImpairmentTrip does NOT zero snapshotRoot/totalClaimSupply/recoveryAsset while clearImpairmentRootAfterChallenge does, and that mintImpairmentClaim + notifyImpairmentRecovery accept status Resolved. Quote lines. List the ImpairmentStatus enum values.
- H-1: Confirm notifyImpairmentRecovery does a raw safeTransfer of epoch.recoveryAsset without syncing tracked coinBalance, and explain how totalAssets() / coinBalance tracking works (where _syncCoinBalance is, what variable tracks the coin balance). Note whether there's a zero-amount guard or cap.
- Existing impairment test files: full paths and what scenarios they cover, plus the test naming/setup conventions used (which base test contract / fixtures). Be precise with file:line citations.
Analysis
Investigating impairment side-pocket contracts for the three audit findings — reading the core module, escrow, claims, and existing tests.
[REDACTED]
Tools invoked: UpdateCurrentStep, Grep, Glob, Glob, Grep
Analysis
[REDACTED]
Tools invoked: Grep, Read, Read, Grep
Analysis
[REDACTED]
Tools invoked: Read, Read, Grep, Read, Read
Analysis
[REDACTED]
Tools invoked: Read, Read, Grep, Grep, Grep
Analysis
[REDACTED]
Tools invoked: Grep, Read, Read
Analysis
[REDACTED]
Tools invoked: Read, Grep
Analysis
[REDACTED]
Tools invoked: Glob, Read
Analysis
[REDACTED]
Tools invoked: UpdateCurrentStep
Analysis
1. C-2 — No cumulative mint cap; escrow claim not bounded by recovered
mintImpairmentClaim / mintFromVault vs epoch.totalClaimSupply
Confirmed. mintImpairmentClaim validates Merkle proof, one mint per account, and non-zero amount, but never compares cumulative minted units to epoch.totalClaimSupply:
function mintImpairmentClaim(uint256 epochId, address account, uint256 amount, bytes32[] calldata proof)
external
onlyDelegateCall
{
if (account == address(0)) revert ZeroAddress();
if (amount == 0) revert InvalidAmount();
ImpairmentEpoch storage epoch = impairmentEpochs[epochId];
if (epoch.status == ImpairmentEpochStatus.None) revert InvalidImpairmentEpoch(epochId);
if (epoch.status != ImpairmentEpochStatus.Finalized && epoch.status != ImpairmentEpochStatus.Resolved) {
revert InvalidImpairmentTransition(epochId);
}
if (impairmentClaimMinted[epochId][account]) revert ClaimAlreadyMinted(epochId, account);
bytes32 leaf = keccak256(abi.encode(epochId, account, amount));
bool valid = MerkleProof.verify(proof, epoch.snapshotRoot, leaf);
if (!valid) revert InvalidClaimProof(epochId, account);
if (impairmentClaims == address(0)) revert RecoveryEscrowNotConfigured();
impairmentClaimMinted[epochId][account] = true;
ICreatorOImpairmentClaims(impairmentClaims).mintFromVault(account, epochId, amount);
}
CreatorOImpairmentClaims.mintFromVault only increments its own counter; no cap check:
function mintFromVault(address account, uint256 epochId, uint256 amount) external {
if (msg.sender != vault) revert Unauthorized();
_mint(account, epochId, amount, "");
totalSupply[epochId] += amount;
}
CreatorORecoveryEscrow.claimRecovery vs recoveredByEpochAsset
Confirmed. claimRecovery increments claimedByEpochAsset and transfers without checking claimedByEpochAsset <= recoveredByEpochAsset:
function claimRecovery(address asset, uint256 epochId, address receiver, uint256 amount) external {
if (msg.sender != vault) revert Unauthorized();
claimedByEpochAsset[epochId][asset] += amount;
IERC20(asset).safeTransfer(receiver, amount);
}
(notifyRecovery only accumulates recovered — lines 24–27 — with no link to claims.)
State available for caps
| Location | Variable | Purpose |
|---|---|---|
CreatorOVaultModuleStorage / epoch struct | totalClaimSupply | Merkle epoch denominator (set in proposeImpairmentRoot) |
CreatorOImpairmentClaims | totalSupply[epochId] | Cumulative ERC-1155 minted per epoch |
ImpairmentEpoch | totalRecovered, totalClaimed | Vault-side recovery accounting |
CreatorORecoveryEscrow | recoveredByEpochAsset[epochId][asset] | Notified recoveries |
CreatorORecoveryEscrow | claimedByEpochAsset[epochId][asset] | Escrow payouts (uncapped vs recovered) |
A natural mint cap: CreatorOImpairmentClaims.totalSupply[epochId] + amount <= epoch.totalClaimSupply (readable via ICreatorOImpairmentClaims(impairmentClaims).totalSupply(epochId)).
A natural claim cap: claimedByEpochAsset[epochId][asset] + amount <= recoveredByEpochAsset[epochId][asset] in escrow, or vault-side epoch.totalClaimed + amountOut <= epoch.totalRecovered (vault already tracks those in claimImpairmentRecovery at 1007 but does not cap escrow calls).
2. C-3 — clearImpairmentTrip vs clearImpairmentRootAfterChallenge; Resolved accepts mint/notify
Enum (named ImpairmentEpochStatus, not ImpairmentStatus)
enum ImpairmentEpochStatus {
None,
Tripped,
Finalized,
Resolved
}
Values: None = 0, Tripped = 1, Finalized = 2, Resolved = 3.
ImpairmentEpoch struct
struct ImpairmentEpoch {
ImpairmentEpochStatus status;
address strategy;
address recoveryAsset;
uint256 reasonCode;
uint256 tripBlock;
uint64 trippedAt;
uint64 finalizedAt;
uint64 resolvedAt;
uint256 totalSharesAtTrip;
uint256 totalClaimSupply;
uint256 excludedBookValue;
bytes32 snapshotRoot;
uint256 totalRecovered;
uint256 totalClaimed;
}
clearImpairmentTrip — does not zero root fields
Only flips status → Resolved, clears strategy flag, resets active epoch and vault mode:
function clearImpairmentTrip(uint256 epochId) external onlyDelegateCall {
if (epochId == 0 || epochId != activeImpairmentEpoch) revert InvalidImpairmentEpoch(epochId);
ImpairmentEpoch storage epoch = impairmentEpochs[epochId];
if (epoch.status != ImpairmentEpochStatus.Tripped) revert InvalidImpairmentTransition(epochId);
epoch.status = ImpairmentEpochStatus.Resolved;
epoch.resolvedAt = uint64(block.timestamp);
strategyImpaired[epoch.strategy] = false;
activeImpairmentEpoch = 0;
vaultMode = VaultMode.Normal;
emit ImpairmentTripCleared(epochId, epoch.strategy);
emit ImpairmentResolved(epochId);
}
No writes to snapshotRoot, totalClaimSupply, or recoveryAsset.
clearImpairmentRootAfterChallenge — does zero them
function clearImpairmentRootAfterChallenge(uint256 epochId) external onlyDelegateCall {
if (epochId == 0) revert InvalidImpairmentEpoch(epochId);
ImpairmentEpoch storage epoch = impairmentEpochs[epochId];
if (epoch.status != ImpairmentEpochStatus.Tripped) revert InvalidImpairmentTransition(epochId);
if (!impairmentRootChallenged[epochId]) revert ImpairmentRootChallengedErr(epochId);
epoch.snapshotRoot = bytes32(0);
epoch.totalClaimSupply = 0;
epoch.recoveryAsset = address(0);
impairmentRootUnlockTime[epochId] = 0;
impairmentRootChallenged[epochId] = false;
emit ImpairmentRootCleared(epochId);
}
mintImpairmentClaim + notifyImpairmentRecovery accept Resolved
Both allow Finalized OR Resolved:
if (epoch.status == ImpairmentEpochStatus.None) revert InvalidImpairmentEpoch(epochId);
if (epoch.status != ImpairmentEpochStatus.Finalized && epoch.status != ImpairmentEpochStatus.Resolved) {
revert InvalidImpairmentTransition(epochId);
}
function notifyImpairmentRecovery(uint256 epochId, uint256 amount) external onlyDelegateCall {
ImpairmentEpoch storage epoch = impairmentEpochs[epochId];
if (epoch.status != ImpairmentEpochStatus.Finalized && epoch.status != ImpairmentEpochStatus.Resolved) {
revert InvalidImpairmentTransition(epochId);
}
Implication: If proposeImpairmentRoot ran then clearImpairmentTrip (not finalize), epoch is Resolved with snapshotRoot / totalClaimSupply / recoveryAsset still populated — mint and notify remain callable.
proposeImpairmentRoot (for context) requires Tripped and writes those fields:
ImpairmentEpoch storage epoch = impairmentEpochs[epochId];
if (epoch.status != ImpairmentEpochStatus.Tripped) revert InvalidImpairmentTransition(epochId);
if (epoch.snapshotRoot != bytes32(0)) revert ImpairmentRootAlreadyFinalized(epochId);
if (totalClaimSupply == 0) totalClaimSupply = epoch.totalSharesAtTrip;
epoch.snapshotRoot = snapshotRoot;
epoch.totalClaimSupply = totalClaimSupply;
epoch.recoveryAsset = recoveryAsset;
3. H-1 — notifyImpairmentRecovery bypasses coinBalance tracking
Raw transfer, no _syncCoinBalance / _pushCreatorCoinExact
function notifyImpairmentRecovery(uint256 epochId, uint256 amount) external onlyDelegateCall {
ImpairmentEpoch storage epoch = impairmentEpochs[epochId];
if (epoch.status != ImpairmentEpochStatus.Finalized && epoch.status != ImpairmentEpochStatus.Resolved) {
revert InvalidImpairmentTransition(epochId);
}
if (impairmentRecoveryEscrow == address(0)) revert RecoveryEscrowNotConfigured();
IERC20(epoch.recoveryAsset).safeTransfer(impairmentRecoveryEscrow, amount);
ICreatorORecoveryEscrow(impairmentRecoveryEscrow).notifyRecovery(epoch.recoveryAsset, epochId, amount);
epoch.totalRecovered += amount;
emit ImpairmentRecoveryNotified(epochId, epoch.recoveryAsset, amount);
}
Contrast with the tracked outbound path:
function _pushCreatorCoinExact(address to, uint256 amount) internal {
IERC20 coin = _creatorCoin();
uint256 beforeBal = coin.balanceOf(address(this));
coin.safeTransfer(to, amount);
uint256 afterBal = coin.balanceOf(address(this));
uint256 spent = beforeBal - afterBal;
if (spent != amount) revert TransferAmountMismatch(amount, spent);
coinBalance = afterBal;
}
How totalAssets() / coinBalance work
- Tracked variable:
coinBalancein module storage (CreatorOVaultModuleStorage.solline 70). - Sync helper:
_syncCoinBalance()readsbalanceOf(address(this))and writescoinBalance:
function _syncCoinBalance() internal returns (uint256 actual) {
IERC20 coin = _creatorCoin();
actual = coin.balanceOf(address(this));
coinBalance = actual;
}
totalAssets()sumscoinBalance(not livebalanceOf) plus non-impaired strategy assets:
function totalAssets() public view onlyDelegateCall returns (uint256) {
// FIX: L-06 — use tracked coinBalance instead of live balanceOf to prevent donation attacks
uint256 total = coinBalance;
uint256 len = strategyList.length;
for (uint256 i; i < len; i++) {
address strategy = strategyList[i];
if (activeStrategies[strategy] && !strategyImpaired[strategy]) {
total += _getStrategyAssetsSafe(strategy);
}
}
return total;
}
(Same pattern in monolithic CreatorOVault.sol at 927–940 and _syncCoinBalance at 1269–1272.)
Guards / caps on notifyImpairmentRecovery
- No zero-amount guard (
amount == 0not reverted). - No check that
amount <= coinBalanceor thatepoch.recoveryAsset == creatorCoin. - No
_pushCreatorCoinExact— whenrecoveryAssetis the creator coin, tokens leave the vault butcoinBalancestays inflated →totalAssets()overstates idle assets until something else syncs.
4. Existing impairment Foundry tests
| File | Base contract | Setup conventions |
|---|---|---|
/home/akitav2/projects/4626/test/CreatorOVault.ImpairmentV1.t.sol | CreatorOVaultImpairmentV1Test is Test | Full vault: MockCreatorCoinImp, CreatorOVault + three modules via setModulesOnce, CreatorOImpairmentClaims + CreatorORecoveryEscrow, ImpairmentMockStrategy, alice deposit 50M, strategy at 50% weight + deployToStrategies. Helper _pickEmptyCodeAddress. |
/home/akitav2/projects/4626/test/vault/CreatorOImpairmentClaims.t.sol | CreatorOImpairmentClaimsTest is Test | Isolated claims contract; mock vault 0xBEEF. |
/home/akitav2/projects/4626/test/vault/CreatorORecoveryEscrow.t.sol | CreatorORecoveryEscrowTest is Test | Isolated escrow + MockToken; mock vault 0xBEEF. |
/home/akitav2/projects/4626/test/vault/strategies/CreatorOVaultStrategies.Rebalance.TimelineSim.t.sol only mentions “impairment” in a comment (line 121) — not impairment contract coverage.
Scenarios covered
CreatorOVault.ImpairmentV1.t.sol:
test_trip_blocksSyncFlows_untilFinalize— Suspect mode blocks deposit/withdraw/max*, deploy/tend/rebalancetest_finalize_allowsCleanBook_resume_and_claim_flow— trip → propose root → finalize → notify recovery → mint claim → claim recovery (happy path)test_finalize_reverts_before_challenge_windowtest_challenge_blocks_finalize_until_root_cleared_and_reproposedtest_claimMint_reverts_before_finalizetest_claimMint_reverts_on_duplicate_mint
CreatorOImpairmentClaims.t.sol: vault-only mint, non-vault revert, non-transferable ERC-1155.
CreatorORecoveryEscrow.t.sol: vault-only notify, vault-only claim transfer, non-vault notify revert.
Not covered (relevant to C-2/C-3/H-1)
- Over-mint vs
totalClaimSupply - Escrow over-claim vs
recoveredByEpochAsset clearImpairmentTripwith proposed root still set + mint/notify onResolvednotifyImpairmentRecoveryeffect oncoinBalance/totalAssets()when recovery asset is creator coin- Zero-amount notify
[REDACTED]
Was this page helpful?