Skip to main content

Parallel analysis lane

[← Source sessions](/audits/fable/key-sessions) · [Transcript archive](/audits/fable/transcripts) · [Executive summary](/audits/fable/findings-summary)
# Parallel analysis lane | Field | Value | | --- | --- | | Session ID | `48da8f25-5e8f-4577-8fa7-d01fce977f9a` | | Lead session | `059adbec-9820-45a8-9c18-399e4a7f9870` | | Record type | Parallel workstream export | :::note[Appendix record] Readable export of a supplementary review session. Tool outputs and system context are omitted; download the [JSONL archive](/audits/fable-chats-4626-2026-06.zip) for the complete log. ::: ## Engagement brief

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:

  1. 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?).
  2. 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.
  3. 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.
  4. 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

LocationVariablePurpose
CreatorOVaultModuleStorage / epoch structtotalClaimSupplyMerkle epoch denominator (set in proposeImpairmentRoot)
CreatorOImpairmentClaimstotalSupply[epochId]Cumulative ERC-1155 minted per epoch
ImpairmentEpochtotalRecovered, totalClaimedVault-side recovery accounting
CreatorORecoveryEscrowrecoveredByEpochAsset[epochId][asset]Notified recoveries
CreatorORecoveryEscrowclaimedByEpochAsset[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.

clearImpairmentRootAfterChallengedoes 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: coinBalance in module storage (CreatorOVaultModuleStorage.sol line 70).
  • Sync helper: _syncCoinBalance() reads balanceOf(address(this)) and writes coinBalance:
    function _syncCoinBalance() internal returns (uint256 actual) {
IERC20 coin = _creatorCoin();
actual = coin.balanceOf(address(this));
coinBalance = actual;
}
  • totalAssets() sums coinBalance (not live balanceOf) 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 == 0 not reverted).
  • No check that amount <= coinBalance or that epoch.recoveryAsset == creatorCoin.
  • No _pushCreatorCoinExact — when recoveryAsset is the creator coin, tokens leave the vault but coinBalance stays inflated → totalAssets() overstates idle assets until something else syncs.

4. Existing impairment Foundry tests

FileBase contractSetup conventions
/home/akitav2/projects/4626/test/CreatorOVault.ImpairmentV1.t.solCreatorOVaultImpairmentV1Test is TestFull 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.solCreatorOImpairmentClaimsTest is TestIsolated claims contract; mock vault 0xBEEF.
/home/akitav2/projects/4626/test/vault/CreatorORecoveryEscrow.t.solCreatorORecoveryEscrowTest is TestIsolated 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/rebalance
  • test_finalize_allowsCleanBook_resume_and_claim_flow — trip → propose root → finalize → notify recovery → mint claim → claim recovery (happy path)
  • test_finalize_reverts_before_challenge_window
  • test_challenge_blocks_finalize_until_root_cleared_and_reproposed
  • test_claimMint_reverts_before_finalize
  • test_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
  • clearImpairmentTrip with proposed root still set + mint/notify on Resolved
  • notifyImpairmentRecovery effect on coinBalance / totalAssets() when recovery asset is creator coin
  • Zero-amount notify

[REDACTED]