Reports

Smart Contract Security Assessment

Cozy Safety Module Review

11
Issues
1
C/H/M
Period
Not specified - Not specified
Auditors
Panda, HHK

Review Summary

Protocol Overview

Protocol
Cozy Safety Module
Timeline
Not specified - Not specified
Audit Team
Panda, HHK

Audit Overview

Scope and Resources

Scope

Overall Assessment

Evaluation Matrix

access control
Good

Role-based permissions well implemented with a clear ownership model.

mathematics
Average

Potential rounding issues in redemption calculations affecting invariants.

complexity
Good

Clean architecture with well-defined responsibilities.

libraries
Average

Some unnecessary imports and dependencies.

decentralization
Average

Governance actors can significantly impact the safety module by pausing it or adding Trigger and Payout addresses, which reduces the level of decentralization.

code stability
Good

The codebase remained stable, with little to no new development introduced during the review.

documentation
Good

Inline comments and function documentation are present.

monitoring
Good

Good event coverage.

testing
Average

Good test coverage and invariant suite, but some findings suggest room for improvement, such as integration tests.

Key Findings

Findings Summary

0
Critical
0
High
1
Medium
2
Low
5
Informational
3
Gas
M-1 Finding

M-1: Some users may not be able to complete their redemption because of a potential underflow

Medium

Summary:

The pool.pendingWithdrawalsAmountmay be smaller than a user's redemption amount due to rounding errors which could lead to underflow and block redemption completion.

Description:

User can redeem from the safety module by calling redeem() which will queue a redemption.
Later on they can call completeRedemption() to finalize the redemption and receive their funds from the safety module.

When a slashing happens, the function slash() will first reduce the reservePool_.pendingWithdrawalsAmount (sum of all redemptions) by the percentage of slashing applied to the pool. Then it saves the percentage into the pendingRedemptionAccISFs.

When completing the redemption, the amount sent to the user will be recomputed using the pendingRedemptionAccISFs to apply any slashing that happened during redemption cooldown to the user.

The issue is that the RedemptionLib library that is in charge of computing the slashing over user's and pool's redemption may end up breaking the invariant pool.pendingWithdrawalsAmount < sum(user's redemption_.totalAssetAmount) due to a rounding errors.

This will lead to some last users not being able to complete their redemption as the function will underflow when subtracting their amount from reservePool_.pendingWithdrawalsAmount inside _completeRedemption().

POC:

function test_pendingUserNotGreaterThanPendingTotal(uint256 depositAmount_, uint256 pendingWithdrawalsAmount_, uint256 slashAmount_, uint256 slashAmount2_) public {
        pool.depositAmount = bound(depositAmount_, 100 ether, 500_000 ether);
        pool.pendingWithdrawalsAmount = bound(pendingWithdrawalsAmount_, 0.1 ether, pool.depositAmount); //can't withdraw more than deposit
        uint256 slashAmount1 = bound(slashAmount_, 0.1 ether, pool.depositAmount - 0.1 ether); //can't slash more than deposit - min bound of second slash
        uint256 slashAmount2 = bound(slashAmount2_, 0.1 ether, pool.depositAmount - slashAmount1); //can't slash more than deposit - first slash

        uint128 userPending = uint128(pool.pendingWithdrawalsAmount); //100% of withdrawals

        //slash
        pool.pendingWithdrawalsAmount = RedemptionLib.updateRedemptionsAfterTrigger(
            pool.pendingWithdrawalsAmount, pool.depositAmount, slashAmount1, reservePoolPendingRedemptionAccISFs
        );
        pool.depositAmount -= slashAmount1;

        console.log("Inverted ratio added by first slash:");
        uint256 firstSlashRatio = reservePoolPendingRedemptionAccISFs[0];
        emit log_uint(reservePoolPendingRedemptionAccISFs[0]);

        //slash again
        pool.pendingWithdrawalsAmount = RedemptionLib.updateRedemptionsAfterTrigger(
            pool.pendingWithdrawalsAmount, pool.depositAmount, slashAmount2, reservePoolPendingRedemptionAccISFs
        );
        pool.depositAmount -= slashAmount2;

        uint256 newUserPending = RedemptionLib.computeFinalReserveAssetsRedeemed(
            reservePoolPendingRedemptionAccISFs, userPending, 1e18, 0
        );

        console.log("Inverted ratio added by second slash:");
        emit log_uint(reservePoolPendingRedemptionAccISFs[0].divWadUp(firstSlashRatio));
        console.log("Inverted ratio after second slash:");
        emit log_uint(reservePoolPendingRedemptionAccISFs[0]);
        console.log("pool.pendingWithdrawalsAmount:");
        emit log_uint(pool.pendingWithdrawalsAmount);
        console.log("pool.depositAmount:");
        emit log_uint(pool.depositAmount);
        console.log("newUserPending:");
        emit log_uint(newUserPending);

        assertGe(pool.pendingWithdrawalsAmount, newUserPending); // WILL REVERT
    }

Impact:

Medium. The invariant doesn't hold which can lead to underflow and dos redemption completion.

Recommendation:

Round up the pendingWithdrawalsAmount by modifying oldAssetsPendingRedemption_.mulWadDown(scalingFactor_); to oldAssetsPendingRedemption_.mulWadUp(scalingFactor_); inside computeNewPendingRedemptionsAccumulatedScalingFactor().

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/130.

L-1 Finding

L-1: User may deposit in a `triggered` module and get slashed as well as pay slighly more protocol fees

Low

Summary:

The Safety module allows deposit even when the protocol is in triggered mode. This means a user unaware may deposit and soon after get his deposit slashed without any benefit from rewards since he just deposited.

Additionally the dripping system only drip fees when the vault is active which means the user will pay slightly more fees than he should have.

Description:

The functions depositReserveAssets() and depositReserveAssetsWithoutTransfer() allow users to deposit into the safety module for a given reserve pool.

When depositing, the state of the safety module is checked, if it's paused it will revert. If it's active it will process normally and if it's triggered it will process normally with the exception of not dripping the fees.

The risk is that a user unaware of the current state of the vault may deposit in a vault that was recently triggered, worth the vault could get triggered while the transaction is being broadcasted. Additionally since the fees are not dripped the user may end up paying more protocol fees that he should have.

Impact:

Low. Users may deposit without knowing a slash is expected to happen very soon as well as pay extra fees.

Recommendation:

  • Add a parameter such as bool allowTriggered to the function and revert if the module is triggered so that users not aware of the incoming slashing are protected while still allowing deposits from the protocol and other actors that might still need to access the function.
  • Update reservePool_.lastFeesDripTime to the current timestamp, while it might forfeit some of the fees for the Cozy protocol it will protect new deposits from paying extra fees.

Developer Response:

We acknowledge this functionality. Depositing while the SafetyModule remains triggered is designed to be used in special situations, not standard user deposits. For example, a protocol may want to use treasury funds to supplement SafetyModule reserve assets prior to slashing and payouts to reduce the impact on the other depositors. The Cozy frontend will alert and/or block users from depositing while a SafetyModule is triggered. The Cozy Safety Module docs will also make this functionality clear.

L-2 Finding

L-2: Reset `lastSharedSafetyModuleUpdate_` when changing state similar to `lastConfigUpdate`

Low

Description:

When the state changes from triggered to active inside slash() and from active to paused inside pause() the lastConfigUpdate is reset to bytes32(0).

This is done to protect the configuration change from accruing delay time while users are not able to redeem their funds.

Similarly, when the shared safety module is updated, there is a delay before it goes into action. But during that delay if slash() or pause() are called, they won't reset the lastSharedSafetyModuleUpdate_.

Which could be an issue since this will allow the update to accrue delay time and be completed as soon as the module goes back to active without users being able to withdraw.

While changing the shared safety module may not have as much impact as changing the configuration, the shared safety module is allowed to trigger the module without having to add new trigger data. Thus, it is important to allow users to redeem from the module before a new shared safety module is added.

Impact:

Low.

Recommendation:

Reset lastSharedSafetyModuleUpdate_ when changing state similar to lastConfigUpdate.

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/128.

I-1 Finding

I-1: Unused import

Informational

Summary:

The identifier is imported but never used within the file

Description:

File: src/CozySafetyModuleManager.sol

5: import {IERC20} from "cozy-safety-module-libs/interfaces/IERC20.sol";

11: import {UpdateConfigsCalldataParams, ReservePoolConfig} from "./lib/structs/Configs.sol";

12: import {Delays} from "./lib/structs/Delays.sol";

src/CozySafetyModuleManager.sol#L5, src/CozySafetyModuleManager.sol#L11, src/CozySafetyModuleManager.sol#L12

File: src/SafetyModuleFactory.sol

4: import {IERC20} from "cozy-safety-module-libs/interfaces/IERC20.sol";

6: import {ReservePoolConfig} from "./lib/structs/Configs.sol";

7: import {Delays} from "./lib/structs/Delays.sol";

src/SafetyModuleFactory.sol#L4, src/SafetyModuleFactory.sol#L6, src/SafetyModuleFactory.sol#L7

File: src/lib/Configurator.sol

4: import {ICommonErrors} from "cozy-safety-module-libs/interfaces/ICommonErrors.sol";

8: import {SafetyModuleState} from "./SafetyModuleStates.sol";

11: import {ISafetyModule} from "../interfaces/ISafetyModule.sol";

13: import {Delays} from "./structs/Delays.sol";

14: import {ReservePool} from "./structs/Pools.sol";

15: import {
16:   ConfigUpdateMetadata,
17:   ReservePoolConfig,
18:   UpdateConfigsCalldataParams,
19:   SharedSafetyModuleUpdateMetadata
20: } from "./structs/Configs.sol";

21: import {TriggerConfig} from "./structs/Trigger.sol";

src/lib/Configurator.sol#L4, src/lib/Configurator.sol#L8, src/lib/Configurator.sol#L11, src/lib/Configurator.sol#L13, src/lib/Configurator.sol#L14, src/lib/Configurator.sol#L15, src/lib/Configurator.sol#L21

File: src/lib/ConfiguratorLib.sol

5: import {IERC20} from "cozy-safety-module-libs/interfaces/IERC20.sol";

23: import {TriggerConfig, Trigger} from "./structs/Trigger.sol";

src/lib/ConfiguratorLib.sol#L5, src/lib/ConfiguratorLib.sol#L23

File: src/lib/FeesHandler.sol

6: import {IReceiptToken} from "cozy-safety-module-libs/interfaces/IReceiptToken.sol";

12: import {ReservePool, AssetPool} from "./structs/Pools.sol";

src/lib/FeesHandler.sol#L6, src/lib/FeesHandler.sol#L12

File: src/lib/Redeemer.sol

4: import {ICommonErrors} from "cozy-safety-module-libs/interfaces/ICommonErrors.sol";

5: import {IDripModel} from "cozy-safety-module-libs/interfaces/IDripModel.sol";

15: import {AssetPool, ReservePool} from "./structs/Pools.sol";

src/lib/Redeemer.sol#L4, src/lib/Redeemer.sol#L5, src/lib/Redeemer.sol#L15

File: src/lib/RedemptionLib.sol

9: import {ReservePool} from "./structs/Pools.sol";

src/lib/RedemptionLib.sol#L9

File: src/lib/SafetyModuleBaseStorage.sol

5: import {IReceiptToken} from "cozy-safety-module-libs/interfaces/IReceiptToken.sol";

src/lib/SafetyModuleBaseStorage.sol#L5

File: src/lib/SafetyModuleCommon.sol

6: import {IERC20} from "cozy-safety-module-libs/interfaces/IERC20.sol";

src/lib/SafetyModuleCommon.sol#L6

File: src/lib/SlashHandler.sol

15: import {Trigger} from "./structs/Trigger.sol";

src/lib/SlashHandler.sol#L15

File: src/lib/StateChanger.sol

7: import {ISafetyModule} from "../interfaces/ISafetyModule.sol";

src/lib/StateChanger.sol#L7

Impact:

Informational

Recommendation:

Clean up the code by removing unused import statements.

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/123

I-2 Finding

I-2: Typos

Informational

Description:

File: src/lib/Redeemer.sol

// @audit: accomodate should be accommodate
95: // Fees were dripped already in this function if the SafetyModule is active, so we don't need to accomodate for

src/lib/Redeemer.sol#L95

Impact:

informational

Recommendation:

Fix the typo.

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/124

I-3 Finding

I-3: `else` block unnecessary

Informational

Summary:

By eliminating the else block and directly returning the values from the if-block, one level of nesting can be removed:

if (foo) {
  return 1;
} else {
  return 2;
}

should be simplified to:

if (foo) return 1;
return 2;

Description:

File: src/lib/SafetyModuleInspector.sol

86: if (safetyModuleState == SafetyModuleState.ACTIVE) {
87:       return totalPoolAmount_
88:         - _getNextDripAmount(
89:           totalPoolAmount_,
90:           cozySafetyModuleManager.getFeeDripModel(ISafetyModule(address(this))),
91:           reservePool_.lastFeesDripTime
92:         );
93:     } else {
94:       return totalPoolAmount_;
95:     }

src/lib/SafetyModuleInspector.sol#L86

Impact:

Informational.

Recommendation:

Remove the else block.

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/125

I-4 Finding

I-4: Rename `redemptionAmount_` parameter inside `updateRedemptionsAfterTrigger()`

Informational

Description:

The function updateRedemptionsAfterTrigger() has a parameter named redemptionAmount_ however it is passed and used as reservePool_.depositAmount.

For this reason it would be less confusing and make more sense to rename the variable to depositAmount_.

Impact:

Informational.

Recommendation:

Rename redemptionAmount_ into depositAmount_

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/129

I-5 Finding

I-5: Double check shared module compatibility in `setSharedSafetyModule()`

Informational

Description:

When adding a shared module through setSharedSafetyModule() and finalizeSetSharedSafetyModule() the contract doesn't ensure that the contract passed is compatible and inherit all the functions needed on a shared safety module.

For safer configuration it could be beneficial to add an ERC165 support interface or a specific function such as isSharedSafetyModule() to the shared safety module interface that could be called inside setSharedSafetyModule() to ensure the contract passed is compatible.

Impact:

Informational.

Recommendation:

Add a function to identify the shared safety module and check it inside setSharedSafetyModule().

Developer Response:

Fix: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/131.

G-1 Finding

G-1: Do not cache immutable values

Gas

Summary:

Caching immutable is not necessary.

Description:

File: src/CozySafetyModuleManager.sol

176: ISafetyModuleFactory safetyModuleFactory_ = safetyModuleFactory;

CozySafetyModuleManager.sol#L176

Impact:

Gas savings.

Recommendation:

Do not declare safetyModuleFactory_ use safetyModuleFactory directly.

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/122

G-2 Finding

G-2: Inefficient Event Emission Order in acceptOwnership()

Gas

Summary:

The acceptOwnership() function creates an unnecessary local variable to store the old owner before emitting the event.

Description:

In the current implementation, a local variable oldOwner_ is created to store the current owner before updating the state variable. This approach requires an extra SLOAD operation and local variable allocation.

File: Ownable.sol
address oldOwner_ = owner;
owner = msg.sender;
emit OwnershipTransferred(oldOwner_, msg.sender);

Ownable.sol#L43-L49

Impact:

Gas savings.

Recommendation:

delete pendingOwner;
emit OwnershipTransferred(owner, msg.sender);
owner = msg.sender;

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/126

G-3 Finding

G-3: `_getCallerRole`can be re-written for more clarity

Gas

Description:

The _getCallerRole function currently uses a temporary variable role_ to store the caller's role before returning it. This approach requires an unnecessary variable declaration and multiple assignments, resulting in higher gas costs.

File: StateChanger.sol
119:   function _getCallerRole(address who_) internal view returns (CallerRole) {
120:     CallerRole role_ = CallerRole.NONE;
121:     if (who_ == owner) role_ = CallerRole.OWNER;
122:     else if (who_ == pauser) role_ = CallerRole.PAUSER;
123:     // If the caller is the Manager itself, authorization for the call is done
124:     // in the Manager.
125:     else if (who_ == address(cozySafetyModuleManager)) role_ = CallerRole.MANAGER;
126:     return role_;
127:   }

Impact:

Gas savings.

Recommendation:

Refactor the function to use early returns instead of a temporary variable:

function _getCallerRole(address who_) internal view returns (CallerRole) {
  if (who_ == owner) return CallerRole.OWNER;
  if (who_ == pauser) return CallerRole.PAUSER;
  if (who_ == address(cozySafetyModuleManager)) return CallerRole.MANAGER;
  return CallerRole.NONE;
}

Developer Response:

Fixed: https://github.com/Cozy-Finance/cozy-safety-module-private/pull/127

Final Remarks

After the findings were presented to the Cozy safety module team, fixes were made and included in several PRs.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability