Guardian's review of Protocol Review for Callisto, published December 2025. The report records 64 findings across 2 review rounds, including 4 critical and 13 high.
- Published
- Review window
- October 13 to November 26, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum
- Sector
- Lending
- 4 Critical
- 13 High
- 18 Medium
- 12 Low
- 17 Informational
Scope
17 files in scope · 4,245 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/policies/CallistoPSM.sol | 217 | 380 |
src/policies/CallistoVault.sol | 718 | 1284 |
src/external/CDPRouter.sol | 75 | 117 |
src/external/CallistoToken.sol | 34 | 52 |
src/external/ConverterToWadDebt.sol | 9 | 23 |
src/external/DebtTokenMigrator.sol | 122 | 206 |
src/external/PSMStrategy.sol | 551 | 1036 |
src/external/VaultStrategy.sol | 95 | 177 |
src/external/cdp/AdminContract.sol | 278 | 480 |
src/external/cdp/BorrowerOperations.sol | 268 | 497 |
src/external/cdp/CallistoChainlinkOracle.sol | 88 | 183 |
src/external/cdp/ChainlinkCoolerOLTVAdapter.sol | 23 | 59 |
src/external/cdp/DebtToken.sol | 100 | 191 |
src/external/cdp/LiquidationManager.sol | 386 | 668 |
src/external/cdp/OHMDelegationManager.sol | 251 | 489 |
src/external/cdp/StabilityPool.sol | 384 | 860 |
src/external/cdp/VesselManager.sol | 646 | 1276 |
Findings 64
Main Review
62 findings · October 13 to November 6, 2025-
C-01 Critical Liquidation Debt Leak Via Index Scaling Logical Error Resolved
Description
VesselManager.redistributeCollAndDebtis responsible for pushing a liquidated borrower’s remaining debt onto the surviving vessels. Today the function converts the incoming debt into “shares” withSharesMathLib.toSharesDown(debt, state.debtIndexBelowOLTV)and then bumps the debt index bydebtShares * RAY / totalShares. Because that conversion divides by the current index, any index above 1.0 (which happens whenever interest has accrued) shrinks the debt before it is reassigned. A liquidation that should redistribute 3 debt tokens only raises the surviving positions by3 × (1 / index), silently dropping the difference. The standalone Forge test attest/external/cdp/VesselManager/VesselManagerRedistributionAccounting.t.soldemonstrates the issue: after one year at 50% APR the index climbs to ~1.5, a liquidated borrower owes ~3 tokens, yet the surviving borrower only absorbs 2 and ~1 token vanishes. This accounting hole lets liquidated borrowers escape part of their debt and leaves the protocol under-collateralized even when the liquidation was solvent.Recommendation
Increase
state.debtIndexBelowOLTVusing the raw debt amount rather than the share-scaled value, for example by addingFixedPointMathLib.mulDiv(debt, SharesMathLib.RAY, state.debtSharesBelowOLTV), so every liquidation redistributes the full liability regardless of how large the index has grown:- uint256 debtSharesBelow = SharesMathLib.toSharesDown(debt, state.debtIndexBelowOLTV); uint256 debtSharesBelowOLTV = state.debtSharesBelowOLTV; if (debtSharesBelowOLTV > 0) { - state.debtIndexBelowOLTV += uint128((debtSharesBelow * SharesMathLib.RAY) / debtSharesBelowOLTV); + uint256 increment = FixedPointMathLib.mulDiv( + debt, + SharesMathLib.RAY, + debtSharesBelowOLTV + ); + state.debtIndexBelowOLTV += uint128(increment); } -
C-02 Critical DoS Of Stability Pool DoS Resolved
Description
Users can deposit COLLAR into the Stability Pool to secure the protocol and earn a portion of interest paid by borrowers and collateral fees from liquidations. Upon deposits/withdrawals, the pool accrues the interest and distributes it to depositors using:
uint256 interest = DebtToken(debtToken).balanceOf(address(this)) - distributedInterest_ - totalDeposits_; if (interest != 0) updateSummary(_REWARD_TOKEN_POSITION, interest, totalDeposits, product_, scale_, epoch_);At a later stage, the asset gain is computed as follows:
uint256 numerator = amountToAdd * Constants.PERCENTAGE_PRECISION + err; // slither-disable-start divide-before-multiply rate = numerator / totalDeposits_;However, it doesn't skip the division if
totalDeposits_is zero.This allows an attacker to borrow COLLAR, donate it to the Stability Pool when it is empty, then all future depositors will face a division by zero error when they try to deposit or withdraw, causing DoS.
Recommendation
Consider skipping the division if
totalDeposits_is zero. -
C-03 Critical Delegation Index Drift Blocks Liquidation Unexpected Behavior Resolved
Description
When liquidating or reducing collateral, the system attempts to auto-rescind gOHM delegations by computing undelegated balance as:
newUndelegatedBalance = totalAccountGOhm - aState.delegatedGOhm;totalAccountGOhm is recomputed on-the-fly from current collateral (cOHM → OHM via ERC-4626 round down, then OHM → gOHM via GOHM.balanceTo round down using the current sOHM/gOHM index). aState.delegatedGOhm is the stored absolute gOHM delegated at an earlier time. Index is assumed to be flat while it could actually increase.
OHMDelegationManager expresses balances in gOHM by converting cOHM → OHM and then OHM → gOHM via GOHM.balanceTo(OHM), which divides by sOHM.index(). In Olympus, sOHM.index() is monotonic non-decreasing: positive rebases increase _totalSupply, decrease _gonsPerFragment, and thus raise index(). Consequently, the same OHM later maps to fewer gOHM, so if a user previously delegated all at a smaller index, the freshly recomputed totalAccountGOhm during liquidation satisfies totalAccountGOhm ≤ aState.delegatedGOhm. The line newUndelegatedBalance = totalAccountGOhm - aState.delegatedGOhm; then underflows and reverts , blocking liquidation.
Impact: Any borrower who has ever delegated 100% of their cOHM can become non-liquidatable after any positive rebase, creating a DoS on liquidations and potential bad-debt accumulation. This is intentional-attack feasible: delegate-all → wait one rebase/force one rebase → liquidation attempts revert.
Recommendation
Consider replacing this code:
uint256 newUndelegatedBalance = totalAccountGOhm - aState.delegatedGOhm;with:uint256 newUndelegatedBalance = totalAccountGOhm > aState.delegatedGOhm ? (totalAccountGOhm - aState.delegatedGOhm) : 0;In every place of the code where current totaltAccountGOhm is compared to the old aState.delegatedGOhm. -
C-04 Critical Batch Liquidation Uses Stale Coll/Debt Indices Logical Error Resolved
Description
When a liquidation occurs, part of the debt and collateral is redistributed to the remaining vessels. This increases both the debt and collateral indices so vessels account for the redistributed amounts. During liquidation, the protocol calculates collateral compensation for the liquidator and the protocol based on the entire vessel collateral, and the remainder is distributed either to the Stability Pool (SP) or to other vessels:
debtToOffset = FixedPointMathLib.min(debt, debtTokenInStabPool); collToSendToSP = (coll * debtToOffset) / debt; debtToRedistribute = debt - debtToOffset; collToRedistribute = coll - collToSendToSP;The liquidation also closes the liquidated vessel. The protocol uses the collateral and debt indices to compute the correct amounts when closing the vessel, handled in
_decreaseVesselAmounts.This works correctly for single-vessel liquidations. However, the protocol also allows liquidating multiple vessels in a single transaction (up to a maximum). In multi-vessel liquidations, the protocol accumulates the total collateral and debt to redistribute and only calls
redistributeCollAndDebtonce, after all vessels are processed, in_finalizeTotalLiquidation:if (total.debtToRedistribute > 0 || total.collToRedistribute > 0) { p.vm.redistributeCollAndDebt(asset, total.collToRedistribute, total.debtToRedistribute); }This is incorrect because the collateral and debt indices are updated only once at the end. While closing each vessel in the batch, the protocol uses stale indices that do not reflect redistribution from earlier vessels in the same batch.
This leads to incorrect calculations of the ICR when liquidating multiple vessels, and can result in under-collateralized vessels not being liquidated due to using stale indices. Consequently, batch liquidations can yield different outcomes than sequential one-by-one processing; a position that would be liquidatable when processed individually may become non-liquidatable in a batch due to stale indices applied within the same transaction.
Recommendation
Consider updating the collateral/debt indices and the aggregate entire debt/collateral after each vessel is liquidated within the batch, rather than only once at the end. This ensures every vessel in the batch is evaluated and settled using the most up-to-date indices.
-
H-01 High Interest Miscount Bricks Vault Withdrawals Logical Error Resolved
Description
CallistoVault._withdrawStrategyOrCallerFundsdecides whether the strategy can cover the Cooler repayment by comparingdebtToRepayagainststrategyBalance, but it computes that balance asstrategy.totalAssetsAvailable() + interest.totalAssetsAvailable()already returns the strategy’s actual withdrawable principal (capped bymaxWithdraw). The extra interest term is merely the amount owed to Cooler, not a token balance stored in the strategy. When interest accrues faster than realised profit, the sum exceeds what the yield vault can return, so the function enters the “strategy covers everything” branch and callsstrategy.divest(debtToRepay, …). That call propagates toyieldVault.withdraw(debtToRepay, …), which reverts because the vault only holds the principal. The public withdraw path therefore reverts and traps user funds even though they are entitled to them.Recommendation
Base the availability check solely on tokens the strategy can actually withdraw (e.g.,
strategy.totalAssetsAvailable()and, if needed, realized profit). If that amount is smaller than the repayment requirement, withdraw only the available balance and collect the shortfall from the caller instead of mixing in the “interest” figure. -
H-02 High Unsold Close Wraps collarDeficit Logical Error Resolved
Description
closeUnsoldAuctionsblindly computes a deficit for every expired auction, even those created viaaddAuctionForUnsoldwhere target is set to zero. When such a zero-target auction sells a portion of its collateral,raisedbecomes positive and the following code wraps:remainingCollateral = rAuction.capacity; deficit = _calcRemainingCOLLAR(rAuction.target, rAuction.raised); unsoldCollateral[collateral] += remainingCollateral; delete rAuction.capacity; totalDeficit += deficit;The helper just performs an unchecked subtraction:
function _calcRemainingCOLLAR(uint256 target, uint256 raised) private pure returns (uint256) { return FPMath.rawSub(target, raised); }Because
FPMath.rawSub(0, raised)underflows to2^256 - raised, the wrap propagates intototalDeficitand thencollarDeficit, permanently corrupting the deficit ledger. The fork PoC inPSMStrategyCloseUnsoldDeficit.t.soldemonstrates the exact wraparound, after which all future unsold auctions mis-account proceeds.Recommendation
Skip this deficit path for zero-target auctions (e.g., guard the subtraction with
if (rAuction.target != 0)) or replace it with a checked subtraction/revert, ensuring unsold auctions can never feed negative deltas intocollarDeficit. -
H-03 High Liquidator Cap Uses Wrong Units Configuration Resolved
Description
LIQUIDATOR_COLL_FEE_CAP_DEFAULTand its min constant are stored as plain integers (uint128(1000)/uint128(10)) even though the comment states they are “denominated in debt tokens”:/// @dev Liquidator fee cap values are denominated in debt tokens; collateral limits are enforced using oracle /// prices. uint128 internal constant LIQUIDATOR_COLL_FEE_CAP_DEFAULT = uint128(1000); // 1000 uint128 internal constant LIQUIDATOR_COLL_FEE_CAP_MAX = type(uint128).max; uint128 internal constant LIQUIDATOR_COLL_FEE_CAP_MIN = uint128(10); // 10When a collateral is registered,
LiquidationManager.addCollateralTypeseedscollateralParams[asset].liquidatorCollFeeCapwith that raw number:/** * @notice Adds a new collateral type * @param asset The collateral asset address to add */ function addCollateralType(address asset) external { _checkAdminContract(); CollateralParams storage state = collateralParams[asset]; state.liquidatorCollFee = Constants.LIQUIDATOR_COLL_FEE_DEFAULT; _setParams( asset, CollateralParams({ liquidatorCollFee: Constants.LIQUIDATOR_COLL_FEE_DEFAULT, liquidatorCollFeeCap: Constants.LIQUIDATOR_COLL_FEE_CAP_DEFAULT, protocolCollFee: Constants.PROTOCOL_COLL_FEE_DEFAULT }) ); }During liquidation
_getCollCompsconverts the cap back to collateral viaMarketMath.debtToCollDown(params.liquidatorCollFeeCap, price);:function _getCollComps(address asset, uint256 price, uint256 entireColl) internal view returns (uint256 liquidatorCollComp, uint256 protocolCollComp) { CollateralParams storage params = collateralParams[asset]; liquidatorCollComp = MarketMath.mulPercentDown(entireColl, params.liquidatorCollFee); uint256 liquidatorCollCompCap = MarketMath.debtToCollDown(params.liquidatorCollFeeCap, price); if (liquidatorCollComp > liquidatorCollCompCap) { protocolCollComp = liquidatorCollComp - liquidatorCollCompCap; liquidatorCollComp = liquidatorCollCompCap; } protocolCollComp += MarketMath.mulPercentDown(entireColl, collateralParams[asset].protocolCollFee); }debtToCollDownmultiplies byConstants.ORACLE_PRICE_SCALE(1e18) before dividing by the oracle price, so it expects the debt input to use 18‑decimal precision, just like COLLLAR’s ERC20 accounting. Because the default cap is 1000 wei instead of 1000e18, the conversion yields about 1e‑6 collateral tokens at a $10 price, while the configured 3% liquidator fee on a typical vessel would be hundreds of tokens. That cap fires on every liquidation, shrinking the liquidator reward to dust and rerouting the remainder to the protocol, which removes the economic incentive to liquidate and risks leaving undercollateralised positions open.Recommendation
Store the cap constants in the same 18‑decimal units the debt token uses (e.g.
uint128(1000 ether)for the default anduint128(10 ether)for the minimum), and ensure any governance updates or migration code applies the same scaling so liquidator payouts reflect the intended maximum. -
H-04 High Redemption Hints Break After Liquidations Logical Error Resolved
Description
VesselManager.getRedemptionHintssubtracts the collateral lot in asset units and then feeds that value intoMarketMath.computeNominalCRalongside share-denominated debt:uint256 newColl = col - collLot; ... partialRedemptionHintNewNICR = MarketMath.computeNominalCR(newColl, syntheticDebtShares);After
redistributeCollAndDebtruns during a liquidation,collIndexincreases, sonewCollmust be converted back to shares. Without that conversion the computed hint drifts from the real post-redemption NICR and the check inredeemCollateralreverts withVM_UnableToRedeemAnyAmount. The PoC attach shows a redemption succeeding before liquidation, then reverting after a realisticLiquidationManager.batchLiquidateVesselscall. Therefore, post-liquidation, hint-guided redemptions revert indefinitely, blocking core deleveraging flows.Recommendation
Convert the simulated collateral into shares before computing the partial NICR so both operands use the same basis:
uint256 newCollShares = SharesMathLib.toSharesDown(newColl, state.collIndex); uint256 nextNICR = MarketMath.computeNominalCR(newCollShares, syntheticDebtShares);Keeping the units aligned preserves redemption hints even after
collIndexgrows. -
H-05 High Migration Zeroes Principal Liquidity Unexpected Behavior Resolved
Description
The
VaultStrategy.migrate(address newStrategy)function deletesprincipalAssets, moves the ERC4626 shares to the new contract and repoints the vault/PSM:function migrate(address newStrategy) external nonzeroAddress(newStrategy) { _requireRole(Roles.MIGRATOR); emit PrincipalAssetsChange(-principalAssets); delete principalAssets; IERC4626 yieldVault_ = yieldVault; uint256 balance = yieldVault_.balanceOf(address(this)); if (balance != 0) yieldVault_.safeTransfer(newStrategy, balance); VAULT.migrateToNewStrategy(newStrategy); PSM.migrateLiquidityProvider(newStrategy); emit Migrated(newStrategy); }The replacement strategy now owns the debt-token shares but reports
principalAssets == 0. PSM redemptions query that value:function swapIn(address to, uint256 assets) external override returns (uint256 collarIn) { ... uint256 available = availableLiquidity(); require(assets <= available, PSM_InsufficientLiquidity(assets, available)); ... } function availableLiquidity() public view override returns (uint256) { return VaultStrategy(liquidityProvider).totalAssetsAvailable(); } function totalAssetsAvailable() public view override returns (uint256) { if (principalAssets < 0) return 0; return Math.min(uint256(principalAssets), yieldVault.maxWithdraw(address(this))); }With
principalAssetszeroed,availableLiquidity()returns zero; everyswapInreverts once the migration executes. Users can no longer redeem COLLAR or repay CDP debt, Stability Pool top-ups stall and the peg backstop vanishes. Vault positions stay collateralized, so no immediate liquidations occur, but redemption flow is permanently frozen, risking a peg drift.This concrete behaviour is bypassed in your integration/fork tests by the use of the
VaultStrategyTestermock contract which implements a mockaddLiquidityfunction that calls_updatePrincipalAssets(int256(assets));.Recommendation
Add a migration-aware principal sync. Either enhance
VaultStrategy.migrateto accept the carried principal and set it on the new strategy immediately, or introduce a single-use governance initializer (e.g.,finalizeMigration(int256 principalAssets)) callable straight after the pointer swap. Ensure the migration runbook invokes this step soprincipalAssetsandavailableLiquiditystay accurate andswapInkeeps working. -
H-06 High Empty PSMStrategy SP Balance Unexpected Behavior Resolved
Description
The PSM allows anyone to burn “excess” COLLAR via a public function that, after consuming PSM-held pending tokens, withdraws up to the entire SP deposit of the PSMStrategy and burns it. Because swapOut() (USDS→COLLAR) immediately credits the minted amount as “excess”, an attacker can perform:
swapOut(large) → increases excessCOLLAR by the minted amount,
burnExcessCOLLAR() (public) → burns pending and withdraws & burns up to all withdrawableCOLLAR() from the Stability Pool (SP).
This can be done right before a liquidation, reducing the PSMStrategy’s SP deposit to ~0, so the strategy receives no liquidation proceeds for that round. Since auctions are created only when the PSMStrategy itself receives liquidation collateral, no new SP-sourced auctions are created for that window, preventing the recycle loop (collateral → auction → COLLAR paid → re-deposit → surplus to Treasury).
Attack Path :
Just before a liquidation ( frontrun ):
Call swapOut(attacker, USDS_amount_large) → mints collarOut and excessCOLLAR += collarOut.
Call burnExcessCOLLAR():
burns collarPendingBurning, then
reads toBurn = min(strategy.withdrawableCOLLAR(), excess),
strategy.withdrawCOLLAR(toBurn); PSM burns that amount.
Liquidation executes; PSMStrategy SP balance ~0 → receives 0 collateral, so no addAuction(...) call is made for the strategy.
Result: Protocol misses proceeds & auctions for that round; attacker/others in SP get a larger pro-rata share.
Recommendation
Restrict public burn to PSM-held pending only; gate strategy withdrawals behind keeper with SP floor/caps/cooldown;
-
H-07 High Olympus Delegate Cap Blocks Depositors Logical Error Resolved
Description
Callisto routes every gOHM delegation through
OHMDelegationManager.applyDelegations, which forwards the batch to Olympus Cooler on behalf of the vault itself:return OLYMPUS_COOLER.applyDelegations({ delegationRequests: requests, onBehalfOf: address(this) // CallistoVault });Olympus’ DLGTE module enforces a hard per-account limit (
DEFAULT_MAX_DELEGATE_ADDRESSES, default 10) whenever it sees a new delegate:(bool alreadyExisted, uint256 existingAmount) = acctDelegatedAmounts.tryGet(delegate); if (!alreadyExisted && acctDelegatedAmounts.length() >= maxDelegates) { revert DLGTE_TooManyDelegates(); } // https://vscode.blockscan.com/ethereum/0xD3204Ae00d6599Ba6e182c6D640A79d76CdAad74: _addDelegationBecause every delegation Callisto submits uses the same
onBehalfOf = address(CallistoVault), that global cap is shared across all depositors. Once ten unique delegates have been added, the eleventh request reverts, even if the depositor is using their own first delegate and Callisto’s local per-user cap (default 3) has plenty of room. Olympus enforces this before Callisto’s manager can react, so subsequentapplyDelegationscalls fail and every later user is prevented from assigning their gOHM voting power.Impact: the moment the shared pool reaches 10 delegates, delegation is broken for everyone who comes after.
Recommendation
Have governance lift the Olympus cap for
address(CallistoVault), either by callingIDLGTEv1.setMaxDelegateAddresses(account, newMax)on the DLGTE policy orIMonoCooler.setMaxDelegateAddresses(address(this), newMax)via the Cooler, so the shared pool can accommodate hundreds or thousands of delegates. Align the value with expected user counts and consider adding an initialization check that asserts the on-chain cap meets Callisto’s per-user limit × anticipated users to catch misconfiguration early. -
H-08 High Donation Desyncs SP Deposit Ledger Logical Error Resolved
Description
donateCOLLARToReduceDeficitaccepts COLLAR, forwards it into theStabilityPooland decrements bothcollarDeficitandcollarPendingReplenishment, but it never raises the internal ledger that tracks the live SP principal. The function ends with the deposit call:STABILITY_POOL.deposit(collarAmount, address(this));while leaving the pre-existing
collarInSPvariable unchanged at its old value. During the next liquidation theStabilityPoolinvokesaddAuction, providingupdatedSPDeposit = calcCompoundedDeposit(address(this)), which now reflects the donation. The strategy immediately computesdepositPendingReplenishment = collarInSP - updatedSPDeposit, so the stale ledger forces a negative subtraction whenever the liquidation consumes less than the donated amount and the transaction reverts. If the subtraction succeeds, the deficit ledger remains understated by the unspent portion, leaving later replenishment logic working off a stale balance. In both cases the auction accounting loses sync with theStabilityPooland deficit recovery for that liquidation cycle stalls.Recommendation
Whenever external
COLLARis donated, bumpcollarInSPby the samecollarAmount(without touchingcollarFromPSM) so the internal accounting mirrors the actualStabilityPoolbalance before any later redistributions. -
H-09 High Zero-Gain Auction Blocks Liquidations Logical Error Resolved
Description
During a liquidation the
StabilityPoolcalls_addAuctionInPSM, which immediately forwards the collateral gain it just claimed toPSMStrategy.addAuction. The helper relies on_calcRateAndErrorto pro-rate gains across deposits. For minuscule offsets, that routine floors the collateral gain rate to zero even though the debt-loss rate remains ≥1, so the PSM deposit shrinks (depositPendingReplenishment > 0) while the claimed gain is exactly zero:// src/external/cdp/StabilityPool.sol:436-437 IPSMStrategy(psmStrategyAddr).addAuction( collateral, gains[collateralPosition], // may be 0 collateralPrice, updatedDeposit );Inside
PSMStrategy.addAuction, the code computes the auction’s end price as:// src/external/PSMStrategy.sol:505-507 uint256 endPrice = depositPendingReplenishment.mulDiv( Constants.ORACLE_PRICE_SCALE, collateralAmount, Math.Rounding.Ceil );When
collateralAmount == 0,mulDivdivides by zero and reverts. BecauseaddAuctionis invoked withinStabilityPool.offset, the entire liquidation reverts, preventing the system from processing liquidations until a larger gain materialises. Impact: small liquidations or rounding dust can DoS the liquidation path, leaving bad debt in the system.Recommendation
Handle the zero-gain case before invoking
addAuction. For example, in_addAuctionInPSM, skip auction creation whengains[collateralPosition] == 0and accumulate the deficit incollarDeficit/unsoldCollateral. Alternatively, guard insidePSMStrategy.addAuctionby requiring a nonzerocollateralAmount(require(collateralAmount != 0, PSMStrategy_ZeroCollateral())) and moving the deficit bookkeeping there so the liquidation never reverts. -
H-10 High Unsold Auctions Break When Deficit Cleared DoS Resolved
Description
When
_purchasehandles an “unsold” auction (target = 0) it reducescollarDeficitby the buyer’s payment and only deposits the portion needed back into theStabilityPool:if (deficit >= collarPayment) { collarDeficit = deficit - collarPayment; collarPaymentWoProfit = collarPayment; } else { uint256 collarProfit = collarPayment - deficit; collarPaymentWoProfit = deficit; // zero once the deficit is cleared delete collarDeficit; treasuryProfit[COLLAR] += collarProfit; }Even when the deficit is already zero, the code falls through with
collarPaymentWoProfit == 0and_purchasestill calls_settleTransfersAndRedepositToSP(collarPayment, collarPaymentWoProfit, …). That helper invokesSTABILITY_POOL.deposit(collarPaymentWoProfit, address(this)). TheStabilityPoolrejects zero deposits, so the transaction reverts. From that moment every purchase against that auction reverts and the remaining collateral becomes stuck inunsoldCollateral.Recommendation
Guard the settlement path when
collarPaymentWoProfitis zero. Skip the SP deposit and only transfer collateral or prevent further purchases once the deficit is cleared by forcing admins to close the auction and handle the remainder manually. The key is to avoid callingSTABILITY_POOL.deposit(0, …)after the deficit has been fully repaid. -
H-12 High Unsorted Liquidations Logical Error Resolved
Description
When in Recovery Mode, the protocol performs a special liquidation that checks a vessel’s ICR and applies different strategies accordingly. The case of interest is when
(ICR >= p.MCR) && (ICR < TCR) && (entireVesselDebt <= p.debtInSP). In this case, the vessel’s entire debt is offset against the debt in SP, and the collateral is capped at MCR. The remaining collateral becomes claimable by the vessel owner as collateral surplus. This occurs without any collateral/debt redistribution.Separately, the protocol allows both sorted and arbitrary liquidations in both normal and Recovery Modes. An arbitrary liquidation processes vessels in an unspecified order.
Consider multiple users who satisfy the above condition, with
ICR_1 > ICR_2, both above MCR and below TCR. To favor the protocol, liquidation should target the lowest ICR first, as it increases TCR the most. However, if an arbitrary liquidation hits the higher-ICR vessel first, it will offset all its debt against the SP and leave collateral as surplus. If the SP balance was exactly equal to the liquidated debt, the following check will subsequently evaluate as true and skip the lower-ICR vessel:// Skip this vessel if ICR is greater than MCR and the Stability Pool is empty if (ICR >= p.MCR && p.debtInSP == 0) { continue; }This keeps the protocol stuck in Recovery Mode. Had a sorted liquidation processed the lowest-ICR vessel first, it would have exited Recovery Mode.
Recommendation
Consider blocking arbitrary liquidations while in Recovery Mode, allowing only sorted liquidations.
-
H-13 High Migration Zeroes Unsold Collateral Unexpected Behavior Resolved
Description
The
PSMStrategy.migrate()function transfers Stability Pool deposits and unsold collateral tokens to a new strategy contract and updates the PSM pointer. However, it doesn't update the unsold collateral in the new PSMStrategy.function migrate(address newStrategy) external override { _requireRole(Roles.MIGRATOR); // Withdraw COLLAR from SP and transfer to new strategy. address[] memory assets = STABILITY_POOL.getAssets(); uint256 collarAmount = STABILITY_POOL.calcCompoundedDeposit(address(this)); STABILITY_POOL.withdraw(collarAmount, newStrategy, address(this)); // Transfer all unsold collateral to new strategy. uint256 assetNum = assets.length; uint256 unsold; for (uint256 i = 1; i < assetNum; ++i) { require(_activeAuctions[assets[i]].length() == 0, PSMStrategy_ActiveAuctionsExist(assets[i])); unsold = unsoldCollateral[assets[i]]; if (unsold > 0) { unsoldCollateral[assets[i]] = 0; IERC20(assets[i]).safeTransfer(newStrategy, unsold); } } // Replace the strategy in the contracts. CallistoPSM(PSM).migrateToNewStrategy(newStrategy); emit Migrated(newStrategy); }With
unsoldCollateralzeroed out and not updated in the new strategy, the new PSMStrategy will not be able to create auctions for the unsold collateral transferred during migration, leaving them sitting idle in the new contract without being utilized.This concrete behaviour is bypassed in your integration/fork tests by the use of the
NewPSMStrategymock contract which implements a mockfinalizeMigrationfunction that updates theunsoldCollateralmapping in the new strategy.Recommendation
Consider adding a migration step to update the
unsoldCollateralmapping in the new strategy contract during the migration process. Or introduce a single-use governance initializer callable straight after the pointer swap. This ensures that the new strategy is fully aware of the unsold collateral it holds, allowing it to manage and auction off these assets as intended. -
H-11 High Router DoS When Vault Needs Debt Tokens Unexpected Behavior Resolved
Description
When
CallistoVault.redeemcan’t cover a withdrawal out of the buffered OHM, it enters the Cooler unwind path inside_withdrawand explicitly sources debt tokens before unstaking collateral:uint256 ohmBalance = pendingOHMDeposits; if (assets <= ohmBalance) { ... // direct payout } else { emit PendingOHMDepositsChanged(-int256(ohmBalance), 0); delete pendingOHMDeposits; ... (uint128 wadDebt, uint256 debtToRepay) = _calcDebtToRepay(gOHMAmountToWithdraw); ... _withdrawStrategyOrCallerFunds( debtToRepay, debtToken_, strategy_.totalAssetsAvailable() + interest, strategy_ ); }If the strategy cannot cover
debtToRepay,_withdrawStrategyOrCallerFundsasks the caller to front the difference:if (debtToRepay <= strategyBalance) { if (debtToRepay != 0) strategy_.divest(debtToRepay, address(this)); return; } uint256 callerContribution = FixedPointMathLib.rawSub(debtToRepay, strategyBalance); debtToken_.safeTransferFrom(msg.sender, address(this), callerContribution);The router invokes redeem with msg.sender == address(this):
if (collToWithdraw != 0) { VAULT.redeem(collToWithdraw, msg.sender, address(this)); }During a user exit, once profits have been swept or liquidity is low, the vault reaches the
safeTransferFromcall above. Because the router never escrows or approves debt tokens to the vault, the transfer reverts with anUsds/insufficient-balanceerror. Any router withdrawal that needs to repay Cooler debt is therefore blocked.Recommendation
Adjust the router so it pre-pulls debt tokens from the user and approves/transfers the exact shortfall to the vault before calling
redeem, or modify the vault to charge the end user directly instead ofmsg.sender. This guarantees_withdrawStrategyOrCallerFundscan obtain the needed debt tokens even when the strategy balance is exhausted. -
M-01 Medium Auto-Rescinds Freeze Unexpected Behavior Resolved
Description
_autoRescindDelegationswalks the delegate list and calls_rescindDelegationwith whatever amount is needed to reach the target undelegated balance. After subtracting the rescind amount,_rescindDelegationimmediately enforces that the remaining position still satisfies the global floor:delegatedBalance -= rescindAmount; ... require( delegatedBalance >= minDelegationAmount, OHMDelegationManager_BelowMinimumDelegationAmount(delegatedBalance, minDelegationAmount) );All delegations were approved under the old
minDelegationAmount. If governance later bumps this parameter, any delegate now sitting below the new threshold causes the require to trip during auto-rescind, and the whole withdrawal/closedown flow reverts. In a live scenario where the limit is tightened, stakers would be unable to withdraw collateral or autodelegate because every attempt to prune leftover delegations would hit this strict guard.Impact: raising the minimum delegation bricks withdrawals for accounts with legacy delegates.
Recommendation
Skip the minimum-delegation guard when
_rescindDelegationis reducing positions as part of an auto-rescind, or normalize legacy delegations before enforcing the new limit: for example, ifdelegatedBalance < minDelegationAmountafter subtraction, delete the entry instead of reverting. -
M-02 Medium Wrong Debt Decimals Accounting Math Resolved
Description
When the vault borrows debts token from Cooler, the debt is saved as WAD, by calling
_updateWadDebtPrincipal. However, all debt token interactions are done in terms of assets, when "investing" for example:uint256 dTokenAmount = _convertWadToDebtToken(wadDebt); strategy_.invest(dTokenAmount);On the other hand, when sweeping profits, the vault deals with strategy interactions with the same asset unit as when calling
_updateWadDebtPrincipal:uint256 strategyProfit = strategy.profit(); if (strategyProfit != 0) strategy.divestProfit(amount, address(this)); // Only decrease debt if the amount exceeds strategy profit (i.e. returning principal). if (amount > strategyProfit) _updateWadDebtPrincipal(-int256(FixedPointMathLib.rawSub(amount, strategyProfit)));This is fine for now, as the current debt token (USDS) is 18 decimals as well, but will break if the vault migrates to a token with non-18 decimals.
Recommendation
Consider converting the debt token amounts to WAD when calling
_updateWadDebtPrincipalinsweepProfit. -
M-03 Medium Arbitrary Swap Data Logical Error Resolved
Description
The
processPendingDepositsfunction can be called by anyone to process pending deposits. When the warm-up period is enabled,OHMmust first be staked for a set duration usingOlympusStaking.stake()and later claimed throughOlympusStaking.claim().In this case, "the Callisto protocol can activate
OHMToGOHMMode.Swap, allowing governance to set a customIOHMSwapperthroughsetSwapMode. The swapper contract can interact with supported exchanges and perform slippage or parameter validations as needed."The problem is that
processPendingDepositsis publicly callable and allows the caller to provide arbitraryswapData. A malicious user could craftswapDatawith manipulated parameters to influence the swap execution, causing value loss to the vault.Recommendation
Restrict who can call processPendingDeposits or validate the provided swapData against trusted parameters to prevent arbitrary or unsafe swap execution.
-
M-04 Medium Warm-up Griefing DoS Resolved
Description
The
processPendingDepositsfunction can be called by anyone to process pending deposits. When the warm-up period is active, OHM must first be staked for a set duration usingOlympusStaking.stakeand later claimed viaOlympusStaking.claim.The problem is that the function allows the caller to specify the
ohmAmount. A malicious user can repeatedly callprocessPendingDepositswith a dust-sized amount, triggering a new warm-up cycle each time. Once this happens, the vault cannot callprocessPendingDepositsagain until the previous stake’s warm-up period has elapsed. This behaviour can be repeatedly exploited, effectively blocking the vault from processing its full pending deposits and delaying strategy investment indefinitely.Recommendation
Modify
processPendingDepositsto use the full pendingOHMDeposits balance when the warm-up period is active, or enforce a minimum to prevent users from initiating dust-sized stakes that block further processing. -
M-05 Medium Unaccounted Interest Logical Error Resolved
Description
The profit function correctly reserves profit to cover outstanding interest reimbursements in the general wadCurrent >= uint256(wadBorrowed) and wadCurrent < uint256(wadBorrowed) cases. However, when wadBorrowed is negative or when wadDebt == 0 under the first condition, the function returns strategyProfit directly without deducting totalInterestClaimsWad. This can result in profit being incorrectly swept to the treasury before reserving funds for user interest reimbursements, delaying payouts to users.
Recommendation
Ensure totalInterestClaimsWad is consistently accounted for across all branches of the profit function so that adequate funds remain available to cover pending interest reimbursements.
-
M-06 Medium Missing Liquidation Check In Execute Access Control Resolved
Description
The processPendingDeposits function includes the _requireVaultNotLiquidated check to prevent pending deposits from being processed if a vault has been liquidated. However, the execute function, which can also process deposits when the warm-up period is zero, does not implement this safeguard. Consequently, if a vault has been liquidated, the execute function could still process pendingOHMDeposits and invest to the strategy, potentially leading to inconsistent state or unintended fund allocation.
Recommendation
Add the _requireVaultNotLiquidated check to the execute function to ensure pending deposits aren’t processed for liquidated vaults.
-
M-07 Medium Emergency Burn Skews Excess Accounting Logical Error Resolved
Description
burnCOLLARInEmergencywithdraws and burns COLLAR from the strategy but never adjustsexcessCOLLAR:function burnCOLLARInEmergency(uint256 amount) external override { _requireRole(Roles.ADMIN); _requireNonzeroAmount(amount); _burnCOLLARInStrategy(PSMStrategy(strategy), amount); emit COLLARBurnedInEmergency(amount); }After the burn,
excessCOLLARstill reflects the pre-burn surplus, so downstream logic believes there is residual excess even though the tokens are gone.swapIncomputesexcess = excessCOLLAR - collarPendingBurningand, expecting excess to exist, diverts incoming COLLAR intocollarPendingBurninginstead of redepositing into the strategy, starving liquidity.burnExcessCOLLARobserves a positive excess but finds no withdrawable COLLAR, leaving the counter elevated indefinitely. Therefore, accounting drifts out of sync and future inflows are misrouted until new COLLAR is minted.Recommendation
Within the
burnCOLLARInEmergencyfunction, decrementexcessCOLLARby the burnt amount (saturating at zero) so state mirrors the actual balance. Consider re-evaluating related bookkeeping variables after emergency burns to keepcollarPendingBurningand strategy inventory aligned. -
M-08 Medium Roles-dependent Contracts Unexpected Behavior Acknowledged
Description
RolesConsumerstores theROLESmodule address in an immutable field set at construction. Contracts that inherit it (e.g.,CallistoToken,PSMStrategy,CallistoTimelock) call_requireRoleagainst that fixed instance. When governance upgrades theROLESmodule viaKernel._upgradeModule, policies usingPolicyRolesConsumerare re-wired to the new module, but these standalone consumers keep hitting the old contract. Any grants or revocations performed on the fresh module never propagate to them, so a role revoked on the newROLESdeployment remains active for those contracts.Example: after the upgrade, governance removes
Roles.MINTERfrom an address on the new module.CallistoToken.mintstill calls_requireRoleon the stale module where the flag remains true, letting the attacker mint indefinitely. The issue persists until those consumer contracts are redeployed or reconfigured manually, bypassing role governance post-upgrade.Recommendation
Refactor role-guarded contracts to fetch
ROLESdynamically. Replace the immutableROLESinRolesConsumerwith a mutable reference set via the kernel (mirroringPolicyRolesConsumer) or add a kernel-only hook that updates the cached module during upgrades. EnsureKernel._upgradeModulecalls that hook so all consumers read from the currentROLESmodule before revocations are expected to take effect. -
M-09 Medium repayDebtAndWithdrawColl Wrongly Reverts Unexpected Behavior Resolved
Description
The
repayDebtAndWithdrawCollfunction inBorrowerOperationsalways reverts when the system is in recovery mode and the user tries to withdraw collateral (coll > 0), even if the repayment would bring the system’s Total Collateral Ratio (TCR) back above the Critical Collateral Ratio (CCR). This happens becausev.isRecoveryModeis checked before the repayment is applied, causing_requireNoCollWithdrawal(coll)to revert immediately. As a result, users cannot atomically repay enough debt to restore system health and withdraw collateral in the same transaction.Recommendation
Consider checking the recovery mode after the user’s debt repayment is applied, so the function can allow collateral withdrawal if the system exits recovery as a result of the repayment.
-
M-10 Medium USDS Loss From Unclaimable Reimbursements Unexpected Behavior Resolved
Description
If a user repays the cooler’s interest but there isn’t enough USDS to distribute to their account, the unpaid amount is tracked as
interestClaimsWadand added tototalInterestClaimsWad. When sufficient USDS becomes available, the user can claim these funds through theclaimInterestReimbursementfunction.However, if the vault is liquidated, calling
claimInterestReimbursementwill revert due to the_validateAndCalculateClaimableWadcheck.In such cases, the admin can call
setEmergencyRedeem(true)to enable theemergencyRedeemfunction. TheemergencyRedeemfunction excludestotalReimbursementClaimsfrom the strategy balance. As a result, users are unable to claim their pending interest reimbursements—sinceclaimInterestReimbursementreverts—and these funds are not redeemable throughemergencyRedeem, leaving them permanently locked in the vault strategy.Recommendation
There are two possible solutions:
- If the intention is to prevent users from claiming reimbursements that are included in the strategy balance during liquidation, consider removing the check there and allow the full strategy balance to be withdrawn.
- If the intention is to reserve those funds for users who repaid protocol interest to the cooler, then remove this check from the claim function.
-
M-11 Medium Sub-share Withdrawals Steal Collateral Rounding Resolved
Description
BorrowerOperations.withdrawColland its variants let a borrower request any coll amount, transfer that exact number of tokens out, and rely on VesselManager to keep the vessel’s accounting in sync. In _decreaseVesselAmounts the withdrawal is converted into shares with SharesMathLib.toSharesDown:uint256 collShares = SharesMathLib.toSharesDown(vc.collDecrease, state.collIndex); ... vessel.collShares -= collShares; ... newColl = SharesMathLib.toAssetsDown(vessel.collShares, state.collIndex);When
collIndexexceeds 1 (which happens after the first redistribution or liquidation), any withdrawal smaller than one share producescollShares == 0. The borrower still receives the ERC20 becauseBorrowerOperationsalways transfers the full coll:vm.sendCollateral(asset, _msgSender(), coll);Yet
vessel.collSharesnever changes, sovm.getVesselPositionkeeps reporting the original collateral and both ICR and NICR remain untouched. Repeating small withdrawals lets the borrower drain real collateral while the protocol still thinks it is fully backed, resulting in eventual insolvency. Note that the amount siphoned per call is the ‘dust’ created by the doubletoSharesDown->toAssetsDownconversion: the actual loss equalscollDecrease - toAssetsDown(toSharesDown(collDecrease, collIndex), collIndex). As soon as liquidations or redistributions lift collIndex above 1, even a 1‑wei withdrawal can route one full collateral unit out of the pool. After major liquidation events that push the index higher, the exploit becomes dramatically more profitable because the unburned shares represent larger and larger real balances.Recommendation
Convert the requested collateral to shares with a rounding-up operation (or explicitly clamp the withdrawal to
toAssetsDownof the burned shares) so that every transfer decrementsvessel.collSharesand the reported collateral. -
M-12 Medium Unaccrued Interest In getRedemptionHints Math Resolved
Description
getRedemptionHintscalculates hints for efficient redemption operations and returns the parameters to pass toredeemCollateral. One of these ispartialRedemptionHintNewNICR, which is later validated inredeemCollateral:(, totals.newDebt, totals.newNICR) = _rebalance(asset, currentBorrower, price, totals.collLot, totals.debtLot); if (FixedPointMathLib.dist(partialRedemptionHintNICR, totals.newNICR) > 5e14) break;The calculation of
partialRedemptionHintNewNICRusesdebtIndexBelowOLTVwithout accruing interest, whileredeemCollateraldoes consider accrued interest. When interest is accrued,debtIndexBelowOLTVincreases. This discrepancy can makepartialRedemptionHintNewNICRconsistently lower than the actual NICR computed inredeemCollateral, causing partial redemptions to fail.Recommendation
Preview interest and use the previewed index instead of the cached one in
collDebtState. Replace:uint256 indexBelow = collDebtState[asset].debtIndexBelowOLTV;with:
(,, uint256 indexBelow,,,) = _calculateInterest(collDebtState[asset]); -
M-13 Medium USDS Profit Locked If Vault Is Liquidated Logical Error Resolved
Description
When the vault enters liquidation mode, users are expected to withdraw their remaining share of assets through
emergencyRedeem. However,emergencyRedeemcomputes withdrawable funds usingtotalAssetsAvailable:function totalAssetsAvailable() public view returns (uint256) { int256 principalAssets_ = principalAssets; if (principalAssets_ < 0) return 0; return Math.min(uint256(principalAssets_), yieldVault.maxWithdraw(address(this))); }This function does not include the profit, meaning the yield strategy’s accumulated profit is never withdrawn. Furthermore, if the admin decides to sweep the funds to the treasury, the
profit()function contains the following check:if (isVaultPositionLiquidated()) return 0;As a result, it will always return 0 if the vault is liquidated, leaving those profits permanently locked in the vault strategy.
Recommendation
Consider either allowing the treasury to withdraw the profit through allowing it in
sweepProfitor distributing it to emergency redeemers through adding the profit tototalAssetsAvailableinemergencyRedeem. -
M-14 Medium Floor Check Dilutes Depositor Rewards Validation Resolved
Description
When an offset amount is less than total deposits, the product is recalculated to deplete deposits. If the product shifts enough,
scaleis incremented by 1:if (scaledP < SCALE_FACTOR) { /* If multiplying the product by a non-zero product factor would reduce the product below * the scale boundary, increment the scale. */ product_ = (rawP * SCALE_FACTOR) / Constants.PERCENTAGE_PRECISION; ++scale_; scale = scale_; emit ScaleUpdated(scale_); }When users withdraw their deposits, the deposit is recomputed to account for product and scale changes in
_calcCompoundedDeposit:compoundedDeposit = (initialDeposit * product_) / pSnapshot / SCALE_FACTOR;After that, the code checks if the deposit is less than a billionth of the initial deposit and, if so, discards it. The comment says this was originally to ensure withdrawals favor the pool, but error corrections now handle rounding:
/* * If a compounded deposit is less than a billionth of the initial deposit, return 0. * * Note. Originally, this line was in place to stop rounding errors making the deposit too large. * However, the error corrections should ensure the error in product "favors the pool", i.e. * any given compounded deposit should be slightly less than its theoretical value. * So, it is unclear whether this line is still really needed. */ if (compoundedDeposit < initialDeposit / SCALE_FACTOR) return 0;This is problematic because discarded deposits are still counted in
totalDepositsbut cannot be claimed by anyone. As a result, rewards distributed to depositors are diluted by the amount of deposits that were thrown away, and would accumulate over time as more deposits are discarded.Recommendation
Consider removing the check that discards small deposits in
_calcCompoundedDeposit. -
M-15 Medium Zero Heart Keeper Rewards Logical Error Resolved
Description
In
CallistoHeart.beatthe contract updateslastBeatbefore it calculates the keeper reward. At theCallistoHeartcontract the code executeslastBeat = currentTime - ((currentTime - lastBeatTime) % freq);and only after that, it callscurrentReward(). InsidecurrentRewardthe first operation isuint48 nextBeat = lastBeat + freq;. BecauselastBeathas already been advanced to the most recent schedule slot,nextBeatends up greater than or equal tocurrentTime, so the guardif (currentTime <= nextBeat) return 0;immediately triggers. Every call to beat therefore returns zero reward regardless of delay, removing the keeper incentive. Without rewards, off-chain automation has no economic reason to run beat, threatening to halt vault/PSM upkeep that relies on this call.Recommendation
Calculate the reward using the pre-update timestamp, for example by storing
lastBeatTimein memory and passing it tocurrentRewardbefore assignment, or by moving thelastBeat = ...line to after reward minting socurrentRewardbases the auction on the prior beat. -
M-16 Medium Loss-rate Increment Can Block Liquidations Math Resolved
Description
When offsetting debt from the Stability Pool, the pool checks if the offset debt equals the entire total deposit. If so,
scaleandproductare reset and theepochis incremented to deplete the pool and deposits:++epoch; delete scale; product = Constants.PERCENTAGE_PRECISION;However, when the offset debt is less than the total deposits, deposits (and total deposits) are reduced by a rate derived from the offset amount. This rate,
debtTokenLossRate, is then used to compute the new product (which scales down deposits):uint256 newProductFactor = Constants.PERCENTAGE_PRECISION - debtTokenLossRate; uint256 rawP = product * newProductFactor; uint256 scaledP = rawP / Constants.PERCENTAGE_PRECISION; // ... snip ... product = scaledP;When calculating the loss rate, the implementation increments the rate by 1 to “favor the pool”:
if (debtTokenLoss) { /* Add 1 to make the error in the quotient positive and the debtToken loss "a little too much" to ensure * the error in any given compounded deposit favors the stability pool. */ ++rate; return (rate, rate * totalDeposits_ - numerator); }If the offset debt is exactly
totalDeposits - X(with smallX), this increment can push the rate to 100%, makingnewProductFactorzero and causing a revert:require(product_ != 0, ZeroProduct());This ultimately blocks liquidations when the offset debt is very close to total deposits.
Recommendation
Consider refactoring the rate incremental logic to only do so if the rate isn't 99%, similar to:
if (rate < Constants.PERCENTAGE_PRECISION - 1) { ++rate; return (rate, rate * totalDeposits_ - numerator); } else { return (rate, 0); } -
M-17 Medium Cooler Principal Ledger Subtracts Interest Logical Error Acknowledged
Description
Repay paths forward the entire Cooler payment (principal + interest) into the principal tracker:
uint256 repaidInWad = OLYMPUS_COOLER.repay({ repayAmountInWad: uint128(currentDebtWad), onBehalfOf: address(this) }); _updateWadDebtPrincipal(-int256(repaidInWad)); uint128 amountRepaidInWad = OLYMPUS_COOLER.repay({ repayAmountInWad: wadDebt, onBehalfOf: address(this) }); _updateWadDebtPrincipal(-int256(uint256(amountRepaidInWad)));Borrowing, however, adds only the principal that was just drawn:
uint128 wadDebt = OLYMPUS_COOLER.borrow({ borrowAmountInWad: type(uint128).max, onBehalfOf: address(this), recipient: address(strategy_) }); _updateWadDebtPrincipal(int256(uint256(wadDebt)));Because
_updateWadDebtPrincipalsubtracts interest as well as principal, the ledger drifts below the true principal after every interest-bearing repayment._coolerInterest()then treats the shortfall as if additional interest were owed:int256 wadBorrowed = wadDebtPrincipal; uint256 wadCurrent = OLYMPUS_COOLER.accountDebt(address(this)); wadDebt = (wadCurrent >= uint256(wadBorrowed)) ? FixedPointMathLib.rawSub(wadCurrent, uint256(wadBorrowed)) : 0;Once those phantom debts accumulate, users are forced to settle “interest” that no longer exists and the inflated numbers feed other flows (e.g. withdrawal debt checks), aggravating the risk of locked funds.
Recommendation
Clamp the principal tracker to principal only. When repaying, subtract at most the outstanding principal portion, never the accrued interest, and only zero it out when the Cooler position is fully closed. One approach is to compare
repaidInWadagainst the current principal and subtractMath.min(repaidInWad, principal)while keeping a separate counter for interest. -
L-01 Low Deactivated Policies Keep Module Access Unexpected Behavior Acknowledged
Description
Kernel._deactivatePolicysimply re-queries the policy for permissions and revokes only the selectors it returns. The call usespolicy_.requestPermissions(), which is a view hook under the policy’s control. A malicious policy can change its internal configuration so that, when deactivation is requested,requestPermissions()yields an empty or truncated array._setPolicyPermissionsthen touches nothing, leaving the originalmodulePermissionsentries untouched. Module guards consult onlymodulePermissions[keycode][policy][selector]and never checkisPolicyActive, so the deactivated policy continues to call privileged module functions even after removal. Therefore, a compromised policy can refuse to relinquish access and retain full control over modules indefinitely.Recommendation
Record the exact permission set granted at activation and reuse that stored list during deactivation. For example, persist
Permissions[]in kernel storage keyed by policy and clear those entries on_deactivatePolicy, or iterate through all modules/selectors and reset the bits without relying on a policy-supplied list. -
L-02 Low Strategy Migration Misses Yield Vault Check Validation Resolved
Description
VaultStrategy.migratehands its entireyieldVaultshare balance to the replacement strategy without proving they target the same vault. The vault only enforces that both strategies reference the same debt token and vault address duringmigrateToNewStrategybut never checksnewStrategy.yieldVault(). If the new deployment was configured with a different underlying yield vault, the received shares are meaningless: the new strategy cannot redeem them, yet the old strategy deleted its own state and permissions. The assets backing those shares remain trapped in the original vault, permanently reducing recoverable capital.Recommendation
Persist the current
yieldVaultaddress during activation andrequire VaultStrategy(newStrategy).yieldVault() == yieldVaultbefore transferring shares. Alternatively, redeem all shares to underlying assets in the legacy strategy, then pass raw assets to the new strategy for redeposit. This avoids any dependency on matching share types. -
L-03 Low Timelock Delay Can Be Set To Zero Warning Resolved
Description
CallistoTimelock.updateDelayis the only path that mutates_minDelay, and it is callable once the timelock schedules a self-targeted operation. The code simply emitsMinDelayChangeand assigns the caller-provided value without validating it. Although the constructor enforces a 1–3 day bound, a proposer can queue anupdateDelay(0)operation respecting the current delay; when it executes,_minDelaybecomes zero. From that moment every privileged action the timelock controls can be scheduled and executed in the same transaction, removing the intended governance safety window.Recommendation
Inside
updateDelay, require the new delay to satisfy the same bounds as the constructor, for examplerequire(newDelay >= MIN_DELAY_BOUND && newDelay <= MAX_DELAY_BOUND, TimelockInvalidDelay());before committing the state change. If different limits are desired, enforce those invariants there so self-updates cannot disable or shrink the timelock without a bounded delay. -
L-04 Low Collateral Withdraw Favors Users Rounding Resolved
Description
In
_decreaseVesselAmounts, collateral decreases convert assets → shares withtoSharesDown:uint256 collShares = SharesMathLib.toSharesDown(vc.collDecrease, state.collIndex);However, this favors the users instead of the protocol. This allows the user to always burn fewer shares than expected.
Recommendation
Consider rounding the conversion in favor of the protocol, e.g., by using
toSharesUpinstead and capping the shares to burn at the vessel's current shares:uint256 collShares = FixedPointMathLib.min(SharesMathLib.toSharesDown(vc.collDecrease, state.collIndex), vessel.collShares); -
L-05 Low Setup Launches With Zero Borrow Fee Configuration Resolved
Description
AdminContract._addNewCollateralseeds every new collateral withBORROWING_FEE_DEFAULT, andsetup()calls it before activating the first asset. The constant is currently 0 ether in theAdminContract, even though the inline comment says 0.5%. As soon as setup finishes, the asset is live andBorrowerOperations._computeNetDebtreads a zero fee, so anyone opening a vessel before the timelock queues and executes asetBorrowingFeeupdate can borrow without paying the intended 0.5% treasury revenue. Because the timelock delay is unavoidable, opportunistic users can front-run the fee change and launch with fee-free borrowing, draining early protocol income.Recommendation
Set
BORROWING_FEE_DEFAULTto the intended 0.5% or delay collateral activation until after governance applies the correct fee through the timelock. -
L-06 Low Disabled Markets Still Mint Debt Unexpected Behavior Resolved
Description
AdminContract.setIsActive(asset, false)flips the collateral’s active flag, andBorrowerOperations.openVesselhonours it, but the adjustment path_activeVesselCheck, invoked byborrowDebt,addCollAndBorrowDebtand other mutators atBorrowerOperations, never revalidatesadmin.getIsActive(asset). Once a borrower has an active vessel they can wait for governance to disable the market, then callborrowDebtto mint fresh COLLAR because the Admin flag is ignored._activeVesselCheckstill reads prices, CCR/MCR and vessel state, so operations succeed andVesselManager.increaseVesselAmountshappily records the new debt. The emergency kill switch therefore fails: hostile holders keep inflating supply and draining collateral after governance attempts to freeze the market.Recommendation
Reintroduce the
getIsActivegate for all adjustment paths (e.g. inside_activeVesselCheckbefore returning) or enforce it down inVesselManager.increaseVesselAmountsso inactive markets cannot mint or adjust debt. -
L-07 Low Missing Debt Token Migration Check Unexpected Behavior Resolved
Description
During a debt token migration the migrator calculates how many assets to move using the old ERC4626 vault:
uint256 assets = IERC4626(strategy.yieldVault()).maxWithdraw(address(strategy));It then calls:
strategy.migrateAsset(newDebtToken, newYieldVault, msg.sender, assets, principalAssetsConverted);Inside
VaultStrategy.migrateAssetthe strategy redeems only the currentmaxRedeemamount:uint256 shares = yieldVault_.maxRedeem(address(this)); uint256 redeemed = yieldVault_.redeem(shares, receiver, address(this)); require(redeemed == assets, VaultStrategy_InvalidRedeem(assets, redeemed)); asset.forceApprove(address(yieldVault), 0); asset = newDebtToken; yieldVault = newYieldVault;If the old ERC4626 enforces withdrawal throttles, returning, for example, 5% via
maxWithdraw/maxRedeem, this equality still passes (both are the throttled amount), but most shares remain in the old vault. Immediately after the redeem the strategy wipes its allowance and pointsasset/yieldVaultat the new vault, so the residual balance is orphaned and locked forever. Migration apparently succeeds, yet principal is stuck and lost.Recommendation
Before updating
asset/yieldVault, assert that the old share balance is zero. A simple guard such as:require(yieldVault.balanceOf(address(this)) == 0, "migration incomplete"); -
L-08 Low Broken Functionality In
borrowDebtLogical Error ResolvedDescription
In the
borrowDebtfunction, when a user wants to borrow, multiple checks are performed depending on whether the system is in recovery mode. If it is in recovery mode, the function checks whether the ICR is above the CCR and whether the new ICR is higher than the old ICR. However, since thev.priceused to compute both ICRs is the same and the user is only increasing their debt, theborrowDebtfunction will always revert in recovery mode.Recommendation
Consider always reverting if in recovery mode without any unneeded checks, if the intention is to block adding debts when in recovery mode
-
L-09 Low getGainsOf Miscomputes Pending Interest Math Resolved
Description
When users claim their gains using
claimGains, it first accrues any pending interest via_accrueInterestAndUpdateSummary, then accrues the account’s gains via_accrueAssetGainsFor, ensuring the user claims everything.On the other hand, users can call
getGainsOfto view pending gains without claiming them. However,getGainsOfdoes not accrue pending interest; instead, it tries to estimate it using:uint256 pendingInterest = DebtToken(debtToken).balanceOf(address(this)) - distributedInterest - totalDeposits_; if (pendingInterest > 0 && totalDeposits_ > 0) { // Calculate additional gains: Initial deposit * Additional sum from pending interest / P snapshot. gains[_REWARD_TOKEN_POSITION] += ( initialDeposit * (pendingInterest * Constants.PERCENTAGE_PRECISION / totalDeposits_ * product) ) / pSnapshot / Constants.PERCENTAGE_PRECISION; }This diverges from the claiming path and leads to miscalculation when the scale diff > 0 (after a large offset). As a result,
getGainsOfcan report incorrect rewards.Recommendation
Consider replacing the whole pending interest calculation in
getGainsOfwith something similar to:gains[_REWARD_TOKEN_POSITION] = rDepositorGains[_REWARD_TOKEN_POSITION]; if (rSums[0] != 0) { (uint256 assetGainRate,) = _calcRateAndError( false, DebtToken(debtToken).balanceOf(address(this)) - distributedInterest - totalDeposits, totalDeposits, gainOffsetErrors[_REWARD_TOKEN_POSITION] ); uint256 rSums0 = rSums[0]; uint256 rNextSums0 = rNextSums[0]; if (scaleSnapshot == scale) rSums0 += assetGainRate * product; else rNextSums0 += assetGainRate * product; gains[_REWARD_TOKEN_POSITION] += _calcNewGain(initialDeposit, pSnapshot, rSums0, rNextSums0, rDepositorSums[0]); } -
L-10 Low Finite Router Approvals Can Exhaust Best Practices Resolved
Description
CDPRoutersets allowances totype(uint128).max:ohm.safeApprove(vault, type(uint128).max); vault.safeApprove(borrowerOperations, type(uint128).max);ERC-20 allowances are
uint256. Usinguint128isn’t “infinite” and can deplete with repeated transfers/withdrawals with time.Recommendation
Consider using
type(uint256).maxfor truly infinite allowances. -
L-11 Low deletedToBalance Reverts Best Practices Resolved
Description
OHMDelegationManager.deletedToBalancereads from anEnumerableMapvia.get. OpenZeppelin’sEnumerableMap.getreverts when the key is missing, so asking for the balance delegated to an address that has never been recorded throws instead of returning 0. That makes the view helper unusable for UIs or off-chain scripts unless they already know the delegate exists and contradicts the typical “balance query returns zero if unset” expectation.Recommendation
Use
tryGet(or first checkcontains) and return 0 whendeletedToAccounthas never been recorded. This keeps the helper read-only and ensures callers can safely probe delegation balances without pre-checking the map. -
I-01 Informational Manager Sweep Can Drain Principal Trust Assumptions Acknowledged
Description
CallistoVault.sweepProfittrusts the internalprofit()ledger to cap withdrawals even though only the manager/admin role can invoke it. Repeated manager-controlled cycles ofwithdrawAllCollateralToPendingOHMfollowed by_handleDepositsToStrategyshrinkwadDebtPrincipalwithout clearing the Cooler loan. OncewadDebtPrincipaldips below zero,profit()short-circuits and exposes the entire strategy balance as surplus.sweepProfitthen lets the same manager pull those funds to TRSRY, even updating the debt ledger downward without sending repayment on-chain:if (amount > strategyProfit) { _updateWadDebtPrincipal(-int256(FixedPointMathLib.rawSub(amount, strategyProfit))); } debtToken.safeTransfer(TRSRY, amount);Because the Cooler liability remains outstanding, depositors are left with an insolvent vault while the treasury receives borrowed principal.
Recommendation
Restore accounting so that principal ledgers cannot drift below the real Cooler debt. When repaying, split interest from principal and only deduct the true principal component from
wadDebtPrincipal, forcing actual on-chain repayment before any sweep exceeds the accruedstrategy.profit(). Additionally, gatesweepProfitcalls on verified external debt balances (e.g., requireaccountDebtto be fully covered) so treasury withdrawals cannot proceed while liabilities remain. -
I-02 Informational Flashloan Guard Blocks Cross-calls Only Warning Acknowledged
Description
TransientFlashloanGuardsimply flips a transient flag insideenableFlashloanGuardand letsprohibitFlashloanrevert if that flag is set. This protects “other” entry points. For example,StabilityPool.depositruns withenableFlashloanGuard, so any attempt to re-enterStabilityPool.withdrawin the same tx hits the guard and reverts. The modifier intentionally does not block re-entering the same function; you can calldepositfrom an ERC20 hook and execute it twice in one transaction. That behavior is expected, but developers might assume the guard enforces single-entry semantics like a traditional reentrancy lock and overlook the need for additional checks on self-reentry in future functions.Recommendation
Do not rely on
enableFlashloanGuardto block a function from re-entering itself. If a new entry point requires single-entry semantics, add an explicit reentrancy guard (e.g., OpenZeppelin’snonReentrant) or a dedicatedrequire(!_flashloanGuard)check in that function. -
I-03 Informational Treasury Withdrawal Event Mislabels Actor Warning Resolved
Description
TRSRYv1definesevent Withdrawal(address indexed policy_, address indexed withdrawer, IERC20 indexed token, uint256 amount);, where the second indexed field is meant to identify the entity exercising the withdrawal approval. InCallistoTreasury.withdrawReservesthe codeemits Withdrawal(msg.sender, to, token, amount). That substitutes the recipient address for the withdrawer, so downstream monitoring sees the beneficiary as the actor and loses visibility into which policy actually pulled funds.Recommendation
Emit the caller for both
policy_andwithdrawer, e.g.emit Withdrawal(msg.sender, msg.sender, token, amount);, or adjust the event definition to include an explicit recipient field so log consumers can differentiate between policy caller and destination. -
I-04 Informational Heart Beat Bricks On Warmup Toggle Unexpected Behavior Resolved
Description
CallistoHeart.beat()always invokes the vault’s automation hook:IExecutableByHeart executable = vault; if (address(executable) != address(0)) executable.execute();While
ohmToGOHMModeremainsZeroWarmup,CallistoVault.execute()only calls_processPendingDepositsZeroWarmup:function execute() external override { _requireRole(Roles.HEART); uint256 ohmAmount = pendingOHMDeposits; if (ohmAmount == 0) return; if (ohmToGOHMMode == ICallistoVault.OHMToGOHMMode.ZeroWarmup) { _processPendingDepositsZeroWarmup(ohmAmount); } }That helper enforces a zero warm-up at runtime:
function _processPendingDepositsZeroWarmup(uint256 ohmAmount) private { _validateAndUpdatePendingDeposits(ohmAmount); _requireZeroWarmupPeriod(); ... }If Olympus staking flips
warmupPeriod()above zero while the vault is still in zero-warmup mode and there are queued deposits,_requireZeroWarmupPeriod()reverts. The revert bubbles back tobeat(), so the Heart stops processing pending deposits and never reaches the PSM or reward code. Admins can manually recover by switching toActiveWarmuporSwapmode, but until they intervene the automation loop is stuck and keepers cannot claim rewards.Recommendation
Have
CallistoVault.execute(or the Heart) detect a non-zero warm-up and either dispatch to the active-warmup handler or skip the vault leg while surfacing an alert. Adding a guard such asif (OLYMPUS_STAKING.warmupPeriod() > 0) return; before _processPendingDepositsZeroWarmupkeeps beats running and gives operators time to flip modes manually. -
I-05 Informational DoS If Yield Vault Rejects Deposits Warning Resolved
Description
Every heartbeat pipelines pending OHM through the vault’s strategy:
// CallistoHeart.beat() IExecutableByHeart executable = vault; if (address(executable) != address(0)) executable.execute();In zero‑warmup mode the vault pushes directly into
_handleDepositsToStrategy, which borrows debt tokens and immediately calls:strategy_.invest(dTokenAmount); VaultStrategy.invest just forwards the call to the external ERC4626 yield vault: uint256 shares = yieldVault.deposit(assets, address(this));There is no guard or fallback. Today sUSDS has no cap, but if that ERC4626 ever sets
maxDeposit(address(this)) < assets, whether via a future upgrade, governance limit, or simply because another tenant saturates capacity, deposit reverts withERC4626ExceededMaxDeposit. Because Heart does not catch the revert, the entirebeat()fails: pending OHM remains stuck, no gOHM is produced, PSM cleanup is skipped and keepers cannot earn rewards. Even though no cap is enforced at the moment, the automation pipeline is brittle against any future limit or third-party saturation of the shared vault.Recommendation
Before calling
yieldVault.deposit, check the allowance (e.g.uint256 cap = yieldVault.maxDeposit(address(this));) and only deposit up to the permitted amount, carrying over the remainder for the next beat. Alternatively wrap the deposit in a try/catch and surface an alert so Heart can continue processing other tasks instead of halting entirely. -
I-06 Informational Uninitialized Asset Updates Cause Event Noise Events Resolved
Description
Calling VesselManager.accrueInterest(asset) for an asset that has not been added via addCollateralType does not revert. Because the collDebtState[asset] struct is zero-initialized, _calculateInterest computes zero deltas (old indices are 0, so indexUp(0, …) → 0 and the index deltas are 0). As a result, accrued interest is 0, _mintInterest(0) mints nothing, but _accrueInterest still updates interestUpdatedAt to block.timestamp and emits InterestAccrued(asset, 0, 0, 0, 0). Functionally this is harmless, but it can be misleading for monitoring/analytics (appears as if interest accrual ran on that asset) and slightly muddies state (timestamp set) before the asset is actually configured, if monitoring only expect one asset from vesselManager and dont check wich one is updated, this could be even more harmfull as they will assume the new index/interest for the asset is 0.
Recommendation
Add this line in _accrueInterest()
function _accrueInterest(address asset) private returns (uint256 interestDebt) { CollDebtState storage state = collDebtState[asset]; // Asset must be added/configured first. require(state.interestUpdatedAt != 0, VM_AssetNotConfigured()); .... -
I-07 Informational Useless Check In
addCollBest Practices ResolvedDescription
When users want to add collateral to a position, if the system is in recovery mode, it allows you to add collateral without any checks, since the system always wants to encourage adding collateral to move out of recovery mode. However, if the system is not in recovery mode, it checks that after adding the collateral, the new
TCRremains above theCCR. This check is redundant because adding collateral can only increase theTCR, and since we are already in normal mode, theTCRis by definition greater than theCCR.Recommendation
Remove the
_requireNewTCRisAboveCCRinaddCollfunction. -
I-08 Informational Consider Using Or Removing The Unused Errors Informational Resolved
Description
src/external/cdp/interfaces/IDebtToken.sol
error CallerNotWhitelisted();
src/external/cdp/interfaces/IStabilityPool.sol
error MaxCollateralsReached();
src/interfaces/ICDPRouter.sol
error CDPRouter_NotUser();error CDPRouter_NotPermitted();error CDPRouter_ZeroAmount();error CDPRouter_NonZeroResidual();
src/interfaces/ICallistoVault.sol
error Vault_MismatchedCoolerAddress();error Vault_MigratorNotSet();
src/interfaces/IVaultStrategy.sol
error VaultStrategy_ZeroValue();
Recommendation
Remove or use unused errors.
-
I-09 Informational Unused yieldVault Constructor Argument Best Practices Resolved
Description
CallistoPSMreceives ayieldVault_constructor parameter and even validates it is non‑zero:constructor(Kernel kernel_, address asset_, address collar, address yieldVault_) Policy(kernel_) { require( address(kernel_) != address(0) && asset_ != address(0) && collar != address(0) && yieldVault_ != address(0), PSM_InvalidParameter() ); COLLAR = IERC20(collar); asset = IERC20(asset_); to18DecimalsMultiplier = 10 ** (18 - IERC20Metadata(asset_).decimals()); }However the value is never written to storage or read afterwards. All other constructor arguments are persisted (
asset_,collar), so dropping this one leaves dead logic and reviewers cannot determine whether a yield vault dependency was intended. The current implementation can silently misconfigure the contract because deployments that pass the wrong yield vault address will still succeed and there is no record of the parameter anywhere on-chain. Impact: protocol deployers may believe the strategy address has been wired intoCallistoPSMwhen in fact it is ignored, so configuration mistakes go undetected and auditing the live contract provides no hint that the constructor argument was intended to matter.Recommendation
Either remove the
yieldVault_argument entirely (and the accompanying!= address(0)check) if it is not needed, or persist it in storage and use it where the contract later references the vault/strategy, so that the constructor input actually matters. -
I-10 Informational setCollateralParameters Skips Activation Event Warning Resolved
Description
AdminContract.setCollateralParametersforce-enables the collateral by writingcollParams.active = truedirectly:function setCollateralParameters(...) external override onlyTimelock exists(asset) { CollateralParams storage collParams = _collateralParams[asset]; collParams.active = true; _setBorrowingFee(...); ... }By toggling the flag in place, the function bypasses
_setIsActive, so noIsActiveChanged(asset, oldActive, active)event fires, and theAdminContract__ActiveStatusUnchangedguard is ignored. If the collateral was already active, this still performs an SSTORE and the monitoring stack will never learn thatsetCollateralParametersactivated the asset. Impact: activation events coming fromsetCollateralParametersare invisible to watchers (nothing ever emitted), and redundant writes waste gas when the flag is already true.Recommendation
Replace the raw assignment with a call to
_setIsActive(asset, true)(or at least emitIsActiveChangedand return early whencollParams.activeis already true) so activation toggles always follow the same event/validation path and no extra SSTORE occurs. -
I-11 Informational Unused Admin Slot Best Practices Resolved
Description
BorrowerOperationsdefinesLocalVariablesOpenVesselwith anAdminContract adminmember, documented as “Reference to the AdminContract for protocol parameters”. InsideopenVesselthe struct is instantiated (LocalVariablesOpenVessel memory v;) but theadminslot is never assigned or read; the function instead uses a separate localAdminContract admin = AdminContract(adminContract);. Consequently the struct stores meaningless data and its NatSpec misleads reviewers into thinking it drives parameter reads. The unused slot also forces tools to suppress warnings.Recommendation
Remove the
adminfield (and the corresponding NatSpec line) fromLocalVariablesOpenVessel, or actually assignv.adminand use it throughout the function. Eliminating the dead member keeps the struct minimal and prevents unused-field warnings. -
I-12 Informational Dead Recovery Branch In WithdrawColl Best Practices Resolved
Description
BorrowerOperations.withdrawCollguards the call with_requireNotInRecoveryMode(v.isRecoveryMode);before performing any state changes. Even so, the function later wraps the post-withdrawal checks inif (!v.isRecoveryMode). Because the earlier require already guaranteedv.isRecoveryMode == false, that branch is dead: the condition is always true, so the compiler still emits a conditional and the code suggests behavior that can never differ. The redundant branch wastes a few gas and increases cognitive load for auditors trying to reason about recovery-mode behavior.Recommendation
Drop the
if (!v.isRecoveryMode)wrapper and execute_requireICRisAboveMCRand_requireNewTCRisAboveCCRunconditionally after the withdrawal logic. This makes it explicit that the checks always run and removes the superfluous conditional. -
I-13 Informational Unused Instruction Struct In Kernel Best Practices Acknowledged
Description
Kernel.soldeclares anInstructionstruct (struct Instruction { Actions action; address target; }) alongside theActionsenum, but the struct is never referenced anywhere in the kernel, modules or policies. All kernel calls accept the action enum and target address as separate parameters, so the struct only inflates the ABI and misleads readers about an instruction-based interface.Recommendation
Remove the unused
Instructiondefinition (and any accompanying comments), or refactor the kernel to actually consumeInstructionobjects when executing actions. Keeping only actively used types makes the contract clearer and avoids exporting pointless ABI entries. -
I-14 Informational adminRole Slot Unused In ROLESv1 Best Practices Resolved
Description
ROLESv1.RoleDatareserves anbytes32 adminRole;word, but the module and its concrete implementationCallistoRolesnever assign or read that field. Every entry in_roles[role_]therefore stores an extra 32-byte slot that serves no purpose, increasing storage costs and implying an admin hierarchy that doesn’t exist.Recommendation
Remove the unused
adminRolefield (and associated NatSpec) fromRoleData, or add explicit logic that populates and enforces it so the extra storage provides value. Keeping the struct lean avoids wasting deployment gas and reduces the risk of future upgrades misinterpreting the state layout. -
I-15 Informational ensureValidRole Scans Padded Zeros Best Practices Acknowledged
Description
CallistoRoles.ensureValidRoleiterates all 32 bytes of the role ID and checks each one against the allowed character set. Role identifiers are short ASCII strings padded with0x00, so once the loop hits the first zero byte it can safely stop. Instead the function keeps looping through the remaining ~27 bytes, repeatedly re-reading zero and performing the same comparisons. That adds unnecessary gas to every role validation.Recommendation
Exit the loop early when
char == 0x00(after verifying earlier bytes) so padded bytes are skipped altogether. This preserves semantics (zeros are already allowed) while cutting redundant iterations and gas. -
I-16 Informational End Price Unreachable At Auction Boundary Logical Error Resolved
Description
The auction uses linear price decay from
startPricetoendPricebased on elapsed time:if (currentTime >= a.endTime) return a.endPrice; return a.startPrice - ((a.startPrice - a.endPrice) * (currentTime - a.startTime) / (a.endTime - a.startTime));Separately,
PSMStrategy.closeUnsoldAuctionscloses auctions when it detects they have ended:if (currentTime >= rAuction.endTime) { ... }Because both checks use
>= endTime, an auction can be closed exactly atendTime, the same instant the pricing function first returnsendPrice. In that boundary case, users have no practical chance to purchase at the final (minimum) price, undermining the intended “last-chance” price point.Recommendation
Consider refactoring the condition in closeUnsoldAuctions, to be strictly greater than the end time, instead of greater than or equal to. This ensures the auction remains open at
endTime, allowing purchases atendPricefor at least one block before it becomes eligible for closure. -
I-17 Informational Missing Reserve Logical Error Acknowledged
Description
When a withdrawal needs debt tokens and the strategy can’t fully cover, the caller fronts the shortfall and receives a withdrawal reimbursement IOU recorded in withdrawalClaimsWad[account] and aggregated in totalWithdrawalClaimsWad. Users later call claimWithdrawalReimbursement() to get repaid in the debt token.
However, profit() only deducts interest IOUs (totalInterestClaimsWad) from the amount deemed sweepable to the treasury. It ignores totalWithdrawalClaimsWad. As a result, sweepProfit() can transfer out the very debt tokens needed to repay users’ withdrawal IOUs.
Treasury profit is taken ahead of repaying users who covered protocol shortfalls.
Recommendation
Add withdrawalClaimsDebt = _convertWadToDebtToken(totalWithdrawalClaimsWad); and subtract it alongside interest.
Remediation Review
2 findings · November 26, 2025-
M-01 Medium Migrate PSM Strategy Could Be DoSed Validation Resolved
Description
H-17 was fixed by introducing an
initializeStateFromMigrationin the PSM strategy that gets called at the very end of the migration that initializes the values of the new strategy according to the old one.// Initialize state of this strategy. collarInSP = collarInSP_; collarFromPSM = collarFromPSM_; collarPendingReplenishment = collarPendingReplenishment_; collarDeficit = collarDeficit_;In that function, the code checks if the contract holds any Collar tokens, and if so, they are deposited into the SP (the previous startegy sends the withdrawn Collar directly to the new strategy):
// Create an initial COLLAR deposit in the SP. uint256 collarBalance = IERC20(COLLAR).balanceOf(address(this)); if (collarBalance != 0) STABILITY_POOL.deposit(collarBalance, address(this));Later, it checks that the current SP deposits is the same as the one in the previous strategy:
// Check that the SP deposit matches the expected value. uint256 actualSPDeposit = STABILITY_POOL.calcCompoundedDeposit(address(this)); require(actualSPDeposit == collarInSP_, PSMStrategy_SPDepositMismatch(actualSPDeposit, collarInSP_));However, this is wrong, as it allows anyone to donate any amount of Collar to the new strategy contract and force the above to revert, DoSing the migration.
Recommendation
Consider refactoring the require to allow additional deposits:
require(actualSPDeposit >= collarInSP_, PSMStrategy_SPDepositMismatch(actualSPDeposit, collarInSP_));In addition to that, the state updates could also be updated to account for the donated amounts, could reflect what is done
donateCOLLARToReduceDeficit. -
L-01 Low Invalid Min Burn Amount Set Configuration Resolved
Description
Callisto PSM constructor sets the minimum burning amount to 100 Collar, however, it does that by:
_setMinBurningAmount(100 * 10 ** (18 - IERC20Metadata(collar).decimals())); // 100 COLLARThe issue is that Collar has 18 decimals, so the calculation results in:
100 * 10 * (18 - 18) = 100 * 10 * 0 = 100This means the minimum burning amount is set to 100 wei of Collar, which is almost 0.
Recommendation
Consider changing the line to:
_setMinBurningAmount(100 * 10 ** IERC20Metadata(collar).decimals()); // 100 COLLAR
No findings match.
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
