Guardian's review of Protocol Review for Kairos, published July 2026. The report records 191 findings across 5 review rounds, including 12 high and 53 medium.
- Published
- Review window
- March 9 to July 9, 2026
- Rounds
- Main Review 1, Remediation Review 1, Main Review 2, Remediation Review 2, Remediation Review 4
- Language
- Solidity
- Chains
- Ethereum, Base
- Sector
- Derivatives, DEXs and AMMs
- 0 Critical
- 12 High
- 53 Medium
- 84 Low
- 42 Informational
Scope
Findings 191
Main Review 1
58 findings · March 9 to 23, 2026-
H-01 High Buyer adapter
realAssetsoverstates NAV Logical Error ResolvedDescription
The buyer adapter reports unsettled swaps as if the buyer can always collect the full theoretical net PnL. The root cause is in the valuation path below, which uses a theoretical number, together with the settlement path below, which pays only against reserved backing:
(uint256 netAmount, uint8 netRecipient) = swapCore.getSwapNetAmount(swapIds[j]); if (netRecipient == 1) { assets += collateralBalance + netAmount; } else { assets += netAmount >= collateralBalance ? 0 : collateralBalance - netAmount; }That looks reasonable at first glance, but
getSwapNetAmountis only a pricing view. It does not apply the hard settlement caps that the protocol enforces when money actually moves. On the buyer-positive path,settleSwappays no more than the backing reserved for that swap:uint256 availableBacking = swap.poolCollateralBacking; settlementAmount = availableBacking >= netObligation ? netObligation : availableBacking; swap.collateralBalance += settlementAmount;So the adapter can report value that simply is not there. If rates move sharply in the buyer's favor,
realAssetscan rise by the full theoreticalnetAmounteven though the buyer can recover only the smaller of that number andpoolCollateralBacking, with liquidation reducing it further in stressed cases. Do note that the MorphoVaultV2accounting path consumes adapterrealAssets()directly. InVaultV2.accrueInterestView, the vault starts from its idle token balance and then addsIAdapter(adapters[i]).realAssets()for each adapter to compute the vault's current asset base. That same path then feedstotalAssets,previewDeposit,previewMint,previewWithdraw,previewRedeem,convertToShares, andconvertToAssets. In other words, an overstated buyer-adapter mark is not just a bad view function: it directly changes ERC4626 share pricing and fee accrual in the vault that holds the adapter. Direct cap accounting is updated from adapterchange, not fromrealAssets, but the vault NAV and share exchange rate are still driven by this inflated figure. A realistic example is a vault where Alice is already deposited through the buyer adapter. One open swap is marked at 150000 tokens of profit byrealAssets, but the position can actually collect only 20000 because that is all the LP side reserved. Bob deposits after the mark-up and gets too few vault shares because the vault believes it is richer than it really is. Alice can then redeem against the inflated NAV before the swap is settled, leaving Bob to absorb the gap once the position closes at the lower, real amount.Recommendation
realAssetsshould use a claimable value, not a theoretical one. The best fix is to add aSwapCoreview that returns the buyer's current conservative recovery after applying the same caps and liquidation logic used by settlement, and then build adapter NAV from that value. At a minimum, the buyer-positive branch must be capped bypoolCollateralBackinginstead of adding the fullnetAmount. -
H-02 High LP share price ignores settlement caps Logical Error Resolved
Description
The LP share price is built from full mark-to-market PnL, but settlement is not. The root cause is the mismatch between the exact pricing and settlement paths below. The pricing path first computes bucket PnL from full accrued plus projected swap payments, without capping that value by the swap's remaining
collateralBalanceorpoolCollateralBacking, and then adds the aggregate unrealized PnL straight into pool value:int256 totalFixedPayment = accruedFixedPayment + remainingFixedPayment; int256 totalFloatingPayment = accruedFloatingPayment + remainingFloatingPayment; if (market.rateType == Types.RateType.BUY_FIXED) { lpPnL = totalFixedPayment - totalFloatingPayment; } else { lpPnL = totalFloatingPayment - totalFixedPayment; }int256 unrealizedPnL = calculateTotalUnrealizedPnL(buckets, market, oracleIndex); if (unrealizedPnL >= 0) { mtmValue = SwapFormulas.safeCast(pool.totalCollateral) + unrealizedPnL; } else { uint256 absLoss = uint256(-unrealizedPnL); if (absLoss >= pool.totalCollateral) { return MIN_SHARE_PRICE; } mtmValue = SwapFormulas.safeCast(pool.totalCollateral - absLoss); } sharePrice = Math.mulDiv(uint256(mtmValue), WAD, pool.totalShares);That number is then used immediately for LP entry and exit. New LPs mint shares against it unless the pool has already hit the special
MIN_SHARE_PRICEfloor, and exiting LPs burn shares against it:sharePrice = getPoolSharePrice(marketId); if (sharePrice == Utils.MIN_SHARE_PRICE && pool.lockedCollateral > 0) { revert Errors.E414(); } sharesToMint = Math.mulDiv(collateralAmount, WAD, sharePrice);sharePrice = getPoolSharePrice(marketId); if (fullAmount) { sharesToRedeem = lpPos.shares; withdrawalAmount = Math.mulDiv(sharesToRedeem, sharePrice, WAD); } else { sharesToRedeem = Math.mulDiv(amount, WAD, sharePrice, Math.Rounding.Ceil); withdrawalAmount = amount; }The problem is that the protocol never guarantees those marks are collectible. When a swap is finally closed, the transfer is clipped to whatever collateral is still there:
if (buyerOwes) { uint256 availableCollateral = swap.collateralBalance; settlementAmount = availableCollateral >= netObligation ? netObligation : availableCollateral; swap.collateralBalance -= settlementAmount; poolValueIncrease = settlementAmount; } else { uint256 availableBacking = swap.poolCollateralBacking; settlementAmount = availableBacking >= netObligation ? netObligation : availableBacking; swap.collateralBalance += settlementAmount; poolValueDecrease = settlementAmount; }That gap is enough to move value between LPs. If the theoretical LP gain is larger than the buyer's remaining collateral, the share price is too high and an LP can withdraw against profit the pool will never collect. If the theoretical LP loss is larger than the backing reserved for that swap, the share price is too low and a new LP can mint too many shares before the loss is clipped on settlement. In both directions the pool is using a number that looks like PnL, but is not the amount the system can actually realize. As an example, imagine that Alice and Bob are the only LPs in a market. A large swap moves sharply in the pool's favor, so the share price rises because the unrealized PnL says the pool is up another 200000 tokens. In reality the buyer has only 40000 tokens of collateral left. Alice withdraws while the share price still reflects the full 200000 gain, so she burns too few shares and walks away with more than her fair share of the pool. When the swap is finally settled and the pool collects only the 40000 that was actually posted, Bob is the one left holding the shortfall.
Recommendation
The share price needs to be based on realizable value, not raw swap economics. The clean fix is to value each live swap individually and cap its contribution by the amount the pool can actually win or lose under the current
collateralBalance,poolCollateralBackingand liquidation rules. If the bucket model is kept, then LP minting and burning should be disabled whenever the bucket aggregate can no longer represent a conservative bound on settlement value. -
H-03 High Buyer adapter allows forced early exits Access Control Resolved
Description
KairosBuyerAdaptertreats everydeallocatecall as if it came from a trusted strategy operator, even though Morpho Vault V2 uses the same adapter entrypoint for both allocator-controlled deallocation and permissionless force-deallocation flows. The adapter receivesselector,senderandassetsspecifically so it can distinguish those contexts, but it discards all three values and only branches on the decoded action type. On theACTION_EARLY_EXITbranch the adapter simply builds a singleExitRequestwithminExitAmount = 0and callsswapCore.exitSwapEarly(requests, address(this)). That call succeeds because the adapter itself is the owner of the swap positions. Nothing in the adapter checks whether the original vault caller was an authorized allocator, a sentinel or an arbitrary outsider entering through the vault's permissionless force-deallocation path.function deallocate( bytes memory data, uint256 assets, bytes4 selector, address sender ) external override onlyParentVault returns (bytes32[] memory ids, int256 change) { selector; sender; assets; ... requests[0] = ExitRequest(swapId, 0); swapCore.exitSwapEarly(requests, address(this)); }In a standard Vault V2 integration, an outsider can therefore ask the vault to force-deallocate this adapter with
ACTION_EARLY_EXITdata andassets = 0. Because the adapter ignores the call context, it will unwind the live buyer position anyway. Because the amount argument is zero, the vault-side force-deallocation penalty is also zero. The result is any third party can force the strategy to realize early-exit fees, realize losses at an unfavorable moment or simply sabotage a vault's intended holding period whenever the underlying Kairos market has early exit enabled.Recommendation
Do not allow
ACTION_EARLY_EXITto run on permissionless force-deallocation calls. The adapter should useselectorand, if needed,senderto distinguish ordinary allocator-drivendeallocatecalls from vault-levelforceDeallocateflows and should revert when early exit is requested from an untrusted context. If the strategy still needs a public force path, restrict that path to deterministic asset recovery actions such as ordinary settlement and keep discretionary strategy actions such as early exit behind explicitly authorized vault operators. -
H-04 High Bucket Overlap Corrupts Share Pricing Validation Resolved
Description
During market creation, SwapCore.createMarket() accepts parameter sets where numBuckets * bucketInterval < swapTerm because Utils.validateMarketParams() only enforces bucketInterval <= swapTerm (and bucket-count bounds) but does not enforce that total bucket coverage spans the full swap term. Once such a market exists, Utils.getBucketId() wraps bucket IDs using (entryTimestamp / bucketInterval) % numBuckets, so a newly opened swap is assigned to a bucket that is still aggregating an older active swap from an earlier cycle. Utils.addSwapToBucket() then merges both positions into the same bucket aggregates even though they were opened in different time windows and should not be priced together. Because those mixed aggregates are consumed by Utils.calculateBucketUnrealizedPnL(), Utils.calculateTotalUnrealizedPnL(), and Utils.calculateLpSharePrice(), LP mark-to-market and share price can become distorted once a bucket ID is reused while prior swaps in that bucket are still active. Depending on rate movement, this either depresses share price below fair value, allowing new depositors to acquire shares cheaply before gains settle, or inflates it above fair value, allowing existing LPs to exit at the expense of later depositors. Since market creation is permissionless, this can arise from accidental misconfiguration due to a non-obvious parameter relationship, and can also be configured intentionally by a malicious deployer to extract mispricing.
Recommendation
Enforce numBuckets * bucketInterval >= swapTerm during market validation so bucket IDs cannot wrap while swaps previously assigned to the same bucket are still active.
Resolution
Guardian: The current market validation still allows a bucket ID to be reused before an earlier swap assigned to that same bucket has expired. During market creation, validateMarketParams() enforces numBuckets * bucketInterval >= swapTerm. That fixes the case where the bucket ring is shorter than the swap term, but it does not guarantee that two live swaps cannot overlap in the same modulo bucket. For example, if swapTerm = 1 day, bucketInterval = 1 hour, and numBuckets = 24, the current validation passes because 24h >= 24h. But a swap opened at 00:59:59 in bucket 0 expires at 24:59:59, while another swap can already be opened in bucket 0 at 24:00:00. For the next 59 minutes and 59 seconds, both swaps are live and aggregated into the same bucket even though the configuration was accepted as valid. Bucket IDs are assigned as bucketId = (entryTimestamp / bucketInterval) % numBuckets, so the bucket ring repeats every numBuckets * bucketInterval seconds. However, a swap can be opened at the very end of a bucket, while the next swap reusing that same bucket ID can be opened at the very start of the next cycle. This means the earliest same-bucket reuse can occur almost one full bucketInterval before the earlier swap expires. As a result, the current validation does not fully prevent overlapping live swaps from being merged into the same bucket, so the protocol can reuse a bucket earlier than intended and continue pricing from mixed bucket state.
Recommendation Update market validation so a bucket cannot be reused while a prior swap assigned to that bucket may still be active. A simple fix is to require numBuckets * bucketInterval >= swapTerm + bucketInterval, which conservatively ensures the next generation of a bucket cannot arrive before the previous one has expired.
-
H-05 High Buyer Deallocate Can Consume Unrelated Swap DoS Resolved
Description
KairosBuyerAdapter.deallocate() assumes buyer settlement paths use assets = 0, but does not enforce that invariant. In Kairos, settlement and liquidation are permissionless, so third parties can cause funds from a settled or liquidated buyer swap to land on the adapter before that swap is deallocated through the normal adapter flow. In Morpho Vault V2, forceDeallocate is also permissionless and accepts caller-supplied assets. Because KairosBuyerAdapter.deallocate() does not require assets == 0 for buyer settlement actions, a third party can call forceDeallocate on a different buyer position with nonzero assets, causing vault-side deallocation logic to consume unrelated idle balance already sitting on the adapter. As a result, tokens that belong to one already-settled swap can be used during deallocation of another swap. When the original settled swap is later deallocated through the normal settled path, the adapter no longer holds enough tokens to forward its expected payout to the vault, causing that deallocation to revert and blocking recovery through the intended flow. Partial consumption of the settled swap is especially problematic because it can cause that swap’s later deallocate() call to revert, blocking recovery of the remaining funds, or allow the shortfall to roll forward across later settled swaps and create a cascading deallocation failure. Because the adapter also reports change based on the targeted swap path while VaultV2 can also pull extra tokens from shared adapter balance, vault-side cap/accounting can also become misaligned with the actual per-swap state.
Recommendation
Enforce assets == 0 for buyer settlement deallocations, including ACTION_MAKE_PAYMENT and ACTION_EARLY_EXIT, so unrelated adapter balances cannot be consumed by vault-side deallocation logic.
-
M-01 Medium Expired swaps keep repricing after maturity Unexpected Behavior Resolved
Description
An expired swap should be economically fixed at maturity. Here it is not. The protocol caps the payment period at the swap term, but it still derives the floating leg from the latest index using the full time from entry to the settlement transaction. As a result, a swap can continue to pick up rate movement after it has already matured.
getSwapNetAmount()shows the pattern clearly. Once the swap passes expiry,timeElapsedis capped atmarket.swapTerm, butderiveRateFromIndex()still usestimeElapsedSinceSwapOpenedand the freshest oracle index:uint256 timeElapsedSinceSwapOpened = block.timestamp - swap.entryTimestamp; if (timeElapsedSinceSwapOpened == 0) { return (0, 1); } uint256 timeElapsed; uint256 swapExpiry = swap.entryTimestamp + market.swapTerm; if (block.timestamp >= swapExpiry) { timeElapsed = market.swapTerm; } else { timeElapsed = block.timestamp - swap.entryTimestamp; } int256 floatingPaymentRate = SwapFormulas.deriveRateFromIndex( swap.entryFloatingIndex, _getFreshIndex(market.referenceRateOracle), timeElapsedSinceSwapOpened );Settlement uses the same approach.
settleSwap()fixesswapDurationat the contractual term for an ordinary expiry, but it still annualizes the floating rate over the full elapsed time up to the moment someone calls settlement:uint256 swapDuration = market.swapTerm; if (swap.isEarlyExit) { swapDuration = block.timestamp - swap.entryTimestamp; } uint256 timeElapsedSinceSwapOpened = block.timestamp - swap.entryTimestamp; int256 floatingPaymentRate = SwapFormulas.deriveRateFromIndex( swap.entryFloatingIndex, oracleIndex, timeElapsedSinceSwapOpened );LP pricing follows the same logic. The bucket path saves
totalElapsedbefore applying the maturity cap and then feeds that uncapped value intoderiveRateFromIndex():uint256 timeElapsed = currentTime > avgEntryTime ? currentTime - avgEntryTime : 0; uint256 totalElapsed = timeElapsed; if (timeElapsed > market.swapTerm) { timeElapsed = market.swapTerm; } int256 accruedFloatingRate = 0; if (totalElapsed > 0 && avgEntryIndex > 0) { accruedFloatingRate = SwapFormulas.deriveRateFromIndex(avgEntryIndex, oracleIndex, totalElapsed); }The effect comes from the math helpers.
deriveRateFromIndex()divides by the elapsed time it receives, andcalculateSwapPayment()later multiplies that annualized rate by the capped settlement duration:function deriveRateFromIndex( uint256 entryFloatingIndex, uint256 closingFloatingIndex, uint256 timeElapsed ) public pure returns (int256) { int256 indexRatio = safeCast(Math.mulDiv(closingFloatingIndex, WAD, entryFloatingIndex)); int256 growthFactor = indexRatio - WAD_INT; return (growthFactor * int256(SECONDS_IN_YEAR)) / int256(timeElapsed); } function calculateSwapPayment( uint256 notional, int256 rateWad, uint256 timeElapsed, uint256 utilFee, uint256 riskPremium ) public pure returns (int256) { uint256 basePayment = Math.mulDiv(notional, abs(rateWad) * timeElapsed, SECONDS_IN_YEAR * WAD); ... }That leaves the floating payment tied to the latest
closingFloatingIndex, not the index at expiry. If the oracle index moves after maturity, the payout moves with it. Whoever chooses when to settle inherits a timing option on post-expiry rate movement, and the same drift bleeds into LP mark-to-market. For example, imagine Alice and Bob opening identical swaps in the same market and block. Both swaps mature on Friday. Alice settles immediately, so her payout reflects the index level at expiry. Bob waits until Sunday because the floating-rate oracle has moved in his favor. Even though both contracts ended on Friday, Bob gets a different settlement result simply because he delayed settlement until after the post-expiry index move had already been incorporated. That is an avoidable pricing error. Two identical expired positions should not settle differently just because one sat around longer after maturity.Recommendation
Once a swap expires, every pricing path should use an expiry snapshot rather than the latest index. The cleanest fix is to freeze the floating leg at
swap.entryTimestamp + market.swapTermand reuse that value everywhere settlement or mark-to-market is computed. If that snapshot is not stored directly, the protocol should reconstruct the index at expiry and use the same capped timestamp in both rate derivation and payment calculation.getSwapNetAmount(),settleSwap()and LP pricing all need to follow the same rule so previews, settlement, and share pricing stay aligned. -
M-02 Medium Morpho adapters return raw cashflow change Logical Error Resolved
Description
Both Kairos adapters return
changeas the amount moved in the current call, not as the change in the adapter's allocation after the action. That does not match the accounting contract expected by Morpho Vault V2, where the vault updates its stored allocation for each returned id by adding the adapter'schange, and expects the running sum of those changes to equal the adapter's current estimated position. The LP adapter is the clearest example. On allocate it returns the deposited assets:ids = new bytes32[](1); ids[0] = adapterId; change = int256(assets);On deallocate it returns the collateral withdrawn in that call:
ids = new bytes32[](1); ids[0] = adapterId; change = -int256(withdrawn);That would only be correct if the adapter's allocation stayed equal to cumulative deposits minus cumulative withdrawals. It does not. The LP position is marked to market through Kairos share price:
uint256 shares = swapCore.getLpShares(marketId, address(this)); if (shares > 0) { uint256 sharePrice = swapCore.getPoolSharePrice(marketId); assets += Math.mulDiv(shares, sharePrice, WAD); }The buyer adapter has the same problem. On allocate it now returns the swap's on-position collateral balance, which is better than returning the raw token spend, but it is still not the same thing as
newAllocation - oldAllocation:ids = new bytes32[](1); ids[0] = adapterId; change = int256(collateralBalance);Its deallocate paths return the payout released in that call:
ids = new bytes32[](1); ids[0] = adapterId; change = -int256(payout);ids = new bytes32[](1); ids[0] = adapterId; change = -int256(received);But the buyer adapter's own valuation is mark-to-market and can drift materially between interactions:
(uint256 netAmount, uint8 netRecipient) = swapCore.getSwapNetAmount(swapIds[j]); if (netRecipient == 1) { assets += collateralBalance + netAmount; } else { assets += netAmount >= collateralBalance ? 0 : collateralBalance - netAmount; }Once value moves between calls, cash moved in the latest allocate or deallocate call is no longer the same thing as the change in the adapter's actual allocation. The vault's stored allocation for
adapterIdthen drifts away from the adapter's real position value. That can break cap enforcement and leave the vault making allocation decisions from stale numbers. For example, imagine a vault with a Kairos LP position that was originally funded with 1000000 tokens. Time passes, the share price rises, and the real position value reaches 1300000, but the vault still records 1000000 for that adapter id because no interaction has happened since the original deposit. Bob allocates another 100000 tokens. The adapter returns+100000, so the vault updates its stored allocation to 1100000 even though the real exposure is already 1400000. From there, cap checks and reallocation decisions are being made against the wrong base number.Recommendation
Both adapters should return the signed difference between the adapter's new allocation value and the vault's previously recorded allocation for that id. In practice, each call should compute the adapter's allocation before the action, compute it again after the action, and return
newAllocation - oldAllocation. For the buyer adapter, that calculation should use a conservative realizable value rather than an uncapped theoretical PnL. -
M-03 Medium Buyer adapter omits escrowed assets from NAV Unexpected Behavior Resolved
Description
KairosBuyerAdaptercan permanently underreport its value after a payout is escrowed inSwapCore. When settlement or liquidation owes funds to the buyer side,Utils._transferOrEscrowattempts the token transfer and writes the amount intoescrowedCollateralif the transfer fails. In that case the settlement path explicitly zeroesbuyerCollateralReleased, sosettlementPayoutsno longer reflects the amount still owed to the adapter.bool transferred = _transferOrEscrow(IERC20(swapToken), swap.userAddress, collateralToRelease, swapId, escrowedCollateral); if (!transferred) { buyerCollateralReleased = 0; }The adapter does not account for that receivable anywhere. Its already-settled deallocation path reads only
settlementPayouts, forwards that amount to the vault, and removes the swap from internal tracking even when the actual payout is sitting inescrowedCollateral. ItsrealAssetsview only sums the adapter's token balance and unsettled swap marks, so once the swap has been removed there is no remaining path that includes the escrowed claim in NAV.if (settled) { uint256 payout = swapCore.settlementPayouts(sid); if (payout > 0) asset.safeTransfer(parentVault, payout); _removeSwapId(mid, sid); change = -int256(payout); return (ids, change); }This means a Morpho vault integrating the adapter can price shares below the value it is actually entitled to recover. A user depositing while the adapter is understated can mint too many vault shares and dilute existing depositors, with the gap realized later if the escrow is eventually claimed.
Recommendation
Treat escrowed amounts as part of adapter value until they are claimed. The simplest fix is to include
swapCore.escrowedCollateral(swapId)in both the already-settled deallocation path and inrealAssets, and to avoid pruning a settled swap from tracking until bothsettlementPayoutsandescrowedCollateralare zero. If the intended behavior is instead to exclude temporarily unreachable payouts from NAV, that policy should be explicit and the adapter should expose a separate flag so parent vaults can halt rather than silently underpricing shares. -
M-04 Medium Latest rate rewrites stale floating accrual Oracle Partially resolved
Description
RateIndexLibaccrues the floating index by taking the latest oracle rate at the moment of update and applying that one rate across the entire time gap since the last update. That is not how floating accrual should work when the oracle interface only gives the protocol a spot rate. If the rate changed during the gap, the next update does not capture the realized path. It rewrites the whole interval using the last sampled value. The core logic is inRateIndexLib.update():function update(RateIndex storage index, address oracle) internal returns (uint256) { uint256 currentTimestamp = block.timestamp; uint256 elapsed = currentTimestamp - index.lastUpdates[oracle]; if (elapsed == 0) return index.indices[oracle]; (int256 currentRate, bool isValid) = IRateOracle(oracle).getRate(); require(isValid, "Oracle rate invalid"); uint8 decimals = IRateOracle(oracle).decimals(); int256 currentFloatingRate = Utils.normalizeToWadSigned(currentRate, decimals); uint256 newIndex = SwapFormulas.calculateNewRateIndex(index.indices[oracle], currentFloatingRate, elapsed); index.indices[oracle] = newIndex; index.lastUpdates[oracle] = currentTimestamp; return newIndex; }The helper it calls makes the problem even clearer. The update is simply
current rate x seconds elapsed:function calculateNewRateIndex( uint256 currentFloatingIndex, int256 rateWad, uint256 secondsElapsed ) public pure returns (uint256) { int256 rateAdjustment = (rateWad * int256(secondsElapsed)) / int256(SECONDS_IN_YEAR); int256 multiplier = WAD_INT + rateAdjustment; if (multiplier <= 0) return MIN_INDEX; uint256 result = Math.mulDiv(currentFloatingIndex, uint256(multiplier), WAD); return result > MIN_INDEX ? result : MIN_INDEX; }That would only be safe if
IRateOracle.getRate()returned the realized average rate for the entire interval since the previous update. It does not. The protocol's own interface describes it as a single spot rate:/// @notice For reference rate and risk premium oracles — provides a single spot rate interface IRateOracle is IKairosOracle { function getRate() external view returns (int256 rate, bool isValid); }The in-tree oracle implementations follow that model. For example, the Aave reference-rate adapter reads the current
variableBorrowRatefrom the protocol data provider:function _getRate(address pool) internal view override returns (uint256) { IAaveProtocolDataProvider aaveDataProvider = IAaveProtocolDataProvider(pool); (, , , , , , uint256 variableBorrowRate, , , , , ) = aaveDataProvider.getReserveData(borrowToken); uint256 rateInWad = (variableBorrowRate * 1e18) / 1e27; return rateInWad; }The Chainlink adapter does the same kind of thing with the latest answer:
function getRate() external view returns (int256 price, bool isValid) { (price, , isValid) = getPriceWithMetadata(); } function getPriceWithMetadata() public view returns (int256 price, uint256 updatedAt, bool isValid) { try feed.latestRoundData() returns ( uint80 roundId, int256 answer, uint256, uint256 _updatedAt, uint80 answeredInRound ) { ... int256 scaledPrice = int256(_scaleToWad(uint256(answer), feed.decimals())); return (scaledPrice, _updatedAt, true); } catch { return (0, 0, false); } }So the index is built from a spot observation, but the math treats that observation as if it had been true for the whole stale interval. That creates a path-dependent pricing error whenever updates are sparse and rates move in the meantime. This is not a theoretical maintenance edge case. Anyone can trigger an update directly:
function updateMarketRateIndex(bytes32 marketId) external { Types.Market storage market = markets[marketId]; if (!market.exists) revert Errors.E300(); rateIndex.update(market.referenceRateOracle); }Normal user flows also call
rateIndex.update()before they proceed.buySwap()is one example:// Update rate indices rateIndex.update(referenceRateOracle);The same update happens in LP deposits, LP withdrawals, settlement, liquidation, and transfer of swap ownership. In practice that means the first user who touches a stale market is the one who decides which latest spot rate gets smeared across the whole missing interval. A realistic example is a market using the Aave reference-rate oracle. Alice opened a
BUY_FIXEDswap a month ago, so she benefits when the floating leg rises. For most of that month Aave's borrow rate sat around 5 percent. Then utilization spikes late on the last day and the live borrow rate jumps to 20 percent. No one has touched the Kairos market during the month, so the rate index is still stale. Bob sees the spike and calls settlement while the rate is elevated.RateIndexLib.update()takes that 20 percent spot rate and applies it to the entire month-long gap. Alice's floating-leg payout is now computed as if the market had spent the whole month near 20 percent, even though the rate only moved there at the end. If the move had gone the other way, the opposite side would have the same opportunity. This contaminates more than one surface. The same stale-gap repricing feeds swap settlement,getSwapNetAmount(), LP share pricing, adapter NAV, and wrapped-position payouts. It is a protocol-level accounting error, not just a bad preview.Recommendation
The floating index needs to be built from realized accrual, not from the latest spot rate stretched across an arbitrary stale interval. There are two reasonable ways to fix that. One is to require a cumulative or checkpointed oracle that can produce the realized average over any interval and derive index growth from that historical data. The other is to checkpoint the oracle rate often enough inside the protocol that each update only covers a period for which the sampled rate is actually intended to apply. What should not remain in place is the current model where a single late sample can rewrite an entire gap. If the protocol keeps the existing spot-rate oracle interface, then every pricing path that depends on
rateIndexshould be treated as unsafe wheneverlastUpdates[oracle]is meaningfully stale. At minimum, the system should bound allowable staleness tightly and refuse to settle or mark to market across large unchecked gaps. That would not make the accrual correct, but it would reduce the size of the mispricing window until a proper historical-rate design is in place. -
M-05 Medium Redeem Can Strand Wrapped Escrow DoS Resolved
Description
For wrapped swaps, swap.userAddress in SwapCore is the SwapPositionWrapper contract. If settlement transfer fails, Utils._transferOrEscrow places funds into escrowedCollateral[swapId], but the swap is still marked as settled. SwapPositionWrapper.redeem() only checks that the swap is settled, and _creditIfNeeded() only reads settlementPayouts[swapId] rather than escrowedCollateral[swapId]. On the escrow path, settlementPayouts is zero, so redeemable[tokenId] remains zero. The holder can still call redeem(), which burns the NFT and deletes tokenIdToSwapId[tokenId]. After that, SwapPositionWrapper.claimEscrow(tokenId) can no longer resolve the swapId, and the holder cannot call SwapCore.claimEscrow(swapId) directly because only swap.userAddress is authorized. This can permanently strand escrowed payout. Because SwapCore.makePayment() is permissionless, settlement may be executed by a third party; a wrapped holder may only observe a settled position and call redeem() without realizing payout is currently parked in escrow, at which point the mapping deletion and burn make it permanently unclaimable.
Recommendation
In SwapPositionWrapper.redeem, reconcile escrow before state deletion and burn: either automatically call swapCore.claimEscrow(swapId) and add the result to redeemable[tokenId], or revert if swapCore.escrowedCollateral(swapId) > 0 to force the holder to call claimEscrow first.
-
M-06 Medium BaseSwapRate getRate() Ignores Live Inputs Oracle Acknowledged
Description
BaseSwapRate exposes setBaseSwapRate(), setReferenceRate(), and baseSwapRateWeightedAverage() as live or configurable rate inputs, but its runtime read path does not use them. getRate() calls latestRoundData(), and latestRoundData() returns baseSwapRateWadMock(). In baseSwapRateWadMock(), min == max == 0.05e18, so the returned rate is effectively fixed at 5%. As a result, any integration that consumes BaseSwapRate through getRate()/latestRoundData() receives the mock constant, even after owner updates or weighted-average changes.
Recommendation
Wire latestRoundData()/getRate() to the intended live source (_baseSwapRate and/or baseSwapRateWeightedAverage()).
-
M-07 Medium Escrow claim cannot bypass blocked owners Unexpected Behavior Acknowledged
Description
SwapCore.claimEscrowassumes an escrowed payout becomes recoverable as soon as an authorized caller retries the claim. That is not always true. The function always sends the escrowed tokens back toswap.userAddress, which is the original swap owner recorded inSwapCoreand it has no way to redirect the payout to a different receiver when that owner is itself the reason the original transfer failed.function claimEscrow(bytes32 swapId) external nonReentrant returns (uint256 amount) { Types.SwapPosition storage swap = swapPositions[swapId]; address recipient = swap.userAddress; if (!_isSenderAuthorized(recipient)) revert Errors.E206(); amount = escrowedCollateral[swapId]; if (amount == 0) revert Errors.E400(); escrowedCollateral[swapId] = 0; IERC20(swapToken).safeTransfer(recipient, amount); }That behavior is harmless when
swap.userAddressis a normal EOA that was only temporarily unable to receive tokens. It breaks down when the recorded owner is an intermediary contract such asKairosBuyerAdapterorSwapPositionWrapper. In those integrations, settlement already routes payout to the intermediary because it owns the swap. If that transfer fails and funds are moved into escrow, the later recovery attempt goes through the same recipient again. The adapter and wrapper helper functions simply call back intoswapCore.claimEscrow, so they do not change the recipient or bypass the blocked address. If the intermediary remains unable to receive the token, escrow recovery keeps reverting and the payout remains stuck even though the beneficiary is known and an authorized caller is available.Recommendation
Do not force escrow claims to repay only
swap.userAddresswhen that address is a custody intermediary. The safest fix is to store enough beneficiary information to let escrow be redirected to the current economic owner or to an explicitly authorized alternate recipient during claim. If the protocol wants to preserve a strict no-redirection model for direct users, add a separate escrow-claim path for wrapper and adapter-owned swaps that forwards the recovered payout to the beneficiary expected by the integration instead of retrying the same blocked recipient. -
M-08 Medium Rate index compounds with update cadence Logical Error Resolved
Description
The variable-rate side of the swap is supposed to accrue like simple APR interest, but that is not what happens once the index is updated more than once. Each call to
RateIndexLib.update()takes the current stored index and applies the return for the elapsed period on top of it:function update(RateIndex storage index, address oracle) internal returns (uint256) { uint256 currentTimestamp = block.timestamp; uint256 elapsed = currentTimestamp - index.lastUpdates[oracle]; ... uint256 newIndex = SwapFormulas.calculateNewRateIndex(index.indices[oracle], currentFloatingRate, elapsed); index.indices[oracle] = newIndex; index.lastUpdates[oracle] = currentTimestamp; return newIndex; }The helper itself looks like a simple-interest step:
function calculateNewRateIndex( uint256 currentFloatingIndex, int256 rateWad, uint256 secondsElapsed ) public pure returns (uint256) { int256 rateAdjustment = (rateWad * int256(secondsElapsed)) / int256(SECONDS_IN_YEAR); int256 multiplier = WAD_INT + rateAdjustment; if (multiplier <= 0) return MIN_INDEX; uint256 result = Math.mulDiv(currentFloatingIndex, uint256(multiplier), WAD); return result > MIN_INDEX ? result : MIN_INDEX; }That only stays simple if it is used once for the whole period. If the same formula is used multiple times, each later update is applied on top of an index that already grew on the earlier update. In other words, the result compounds. A simple example shows the difference. If the reference rate stays at 10 percent for a full year, one update at the end gives 10 percent accrual. But if the index is updated halfway through the year and then again at the end, the result is 10.25 percent. Nothing about the rate changed. The only thing that changed was how often the index was refreshed. That means two swaps with the same start time, end time, and oracle rate can settle to different values just because one path had more checkpoints. This is separate from the stale-rate issue. There, the problem is that a late spot rate gets applied to an old interval. Here, even a flat rate still gives different results when the update cadence changes. This directly affects real positions because
SwapCorestores the rate index when the swap is opened:Types.SwapPosition memory buyerSwapPosition = Types.SwapPosition({ ... entryFloatingIndex: rateIndex.getIndex(referenceRateOracle), ... });Later, the protocol uses the gap between that entry index and the current one to work out the variable-rate payment:
int256 floatingPaymentRate = SwapFormulas.deriveRateFromIndex( swap.entryFloatingIndex, _getFreshIndex(market.referenceRateOracle), timeElapsedSinceSwapOpened );So this affects
getSwapNetAmount, settlement, liquidation checks, and LP pricing. The impact is wider because the index is shared per oracle, not per swap:/** * @dev Rate indices are shared across all markets using the same reference rate oracle, * ensuring more frequent updates and consistent accrual across markets */Any update to that oracle changes the outcome for every open swap that uses it, including the public refresh path:
function updateMarketRateIndex(bytes32 marketId) external { Types.Market storage market = markets[marketId]; if (!market.exists) revert Errors.E300(); rateIndex.update(market.referenceRateOracle); }So value does not depend only on the rate path. It also depends on how often the protocol was touched. A user on the side that benefits when the variable rate goes their way can improve their result by forcing more checkpoints, and activity in another market can also change an open position if both markets share the same oracle. That is the core issue: the protocol presents this side of the swap like simple APR accrual, but in practice more updates can increase the variable-rate payout even when the rate itself did not move.
Recommendation
The protocol should choose one model and implement it consistently. If this side of the swap is supposed to use simple APR, then the protocol should stop rolling sub-period returns into a growing index. A safer design is to store cumulative
rate * timeand settle from that directly. If compounding is actually intended, then the docs, pricing assumptions, collateral logic, and oracle requirements should all say that clearly. Either way, checkpoint frequency should not change value when the underlying rate path is unchanged. -
M-09 Medium LP full withdraw skips forcedeallocation penalty Logical Error Resolved
Description
KairosMarketAdapter.deallocate()exposes a customfullAmountmode that is not compatible with Morpho Vault V2's force-deallocation penalty model. In normal Morpho flow,forceDeallocate()is permissionless and computes the penalty from the caller-suppliedassetsamount:bytes32[] memory ids = deallocateInternal(adapter, data, assets); uint256 penaltyAssets = assets.mulDivUp(forceDeallocatePenalty[adapter], WAD); uint256 penaltyShares = withdraw(penaltyAssets, address(this), onBehalf);The LP adapter adds a second deallocation mode where the caller can encode
fullAmount = trueand redeem all LP shares while passingassets = 0:if (!fullAmount && assets == 0) revert ZeroAmount(); uint256 withdrawn = swapCore.withdrawCollateral(marketId, assets, fullAmount, address(this), minCollateralOut); if (fullAmount) { if (withdrawn > 0) { asset.safeTransfer(parentVault, withdrawn); } } change = -int256(withdrawn);That means any external user can call the vault's permissionless
forceDeallocate()withassets = 0and adapter data that setsfullAmount = true. The adapter will still unwind the entire unlocked LP position and push the realized collateral back to the vault, but the vault computes the penalty on zero assets, sopenaltyAssetsandpenaltySharesare also zero. As a concrete example, imagine Alice deposited into a Morpho vault that usesKairosMarketAdapteras its liquidity adapter and governance configured a 10 percent force-deallocation penalty specifically to stop opportunistic exits from disrupting the LP strategy. Bob does not need allocator privileges. He can callforceDeallocate(adapter, abi.encode(marketId, 0, true), 0, Alice)as soon as the market has any unlocked liquidity. The adapter exits the LP position, the collateral returns to the vault and Alice loses no shares because the penalty is computed from zero. Bob has forced the vault out of the strategy for free, even though governance explicitly configured a penalty to make that behavior costly.Recommendation
Do not expose this custom full-withdraw path through the standard Morpho
deallocate()hook. The safest fix is to removefullAmountsupport fromKairosMarketAdapter.deallocate()entirely and require all deallocations to use the vault's explicitassetsamount so Morpho's penalty logic remains meaningful. If the team still wants an all-shares unwind path, it should be implemented as a separate privileged operation that is not reachable through permissionlessforceDeallocate(). At minimum, the adapter should rejectfullAmountwhen the call context corresponds to force deallocation, or it should require a nonzero penalty basis that matches the actual amount being unwound. -
M-10 Medium AbstractReferenceRate fabricates freshness Oracle Acknowledged
Description
AbstractReferenceRatemakes every child oracle look fresh and valid even when it has no basis for doing so. It always stamps the answer with the current block time and always returnsisValid = trueas long as_getRate(pool)does not revert:function latestRoundData() public view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) { uint256 rate = _getRate(pool); return (0, int256(rate), block.timestamp, block.timestamp, 0); } function getRate() external view virtual returns (int256 rate, bool isValid) { (, int256 answer, , , ) = latestRoundData(); return (answer, true); }The rest of the protocol trusts that metadata.
Utils.getOracleRate(),RateIndexLib.update()andTWAPOracleall usegetRate()as a health check. This matters forReferenceRateCompounddirectly. It derives its rate from Compound'sgetUtilization()andgetBorrowRate(utilization), but Compound notes thatgetUtilization()does not accrue interest first. Even so, the wrapper still reportsupdatedAt = block.timestampandisValid = true, so downstream code cannot tell a fresh reading from a stale one. The same problem applies to any child that returns a bad value without reverting. A zero or degraded rate is upgraded into a healthy-looking(rate, true)response with a fresh timestamp. That weakens market creation checks, TWAP wrapping and any runtime path that assumesisValidactually means the oracle is healthy.Recommendation
Do not fabricate oracle health in the base class. Child adapters should return source-level metadata such as
(rate, updatedAt, isValid), or the base should revert when freshness and validity cannot be established. At minimum,AbstractReferenceRateshould stop usingblock.timestampas the source update time and stop returningtrueby default for every non-reverting child call. -
M-11 Medium TWAP history corrupts on source rotation Unexpected Behavior Acknowledged
Description
TWAPOracleexposessetReferenceSpotRateAddress()so the owner can replace the underlying spot-rate source, but the contract does not reset any of the historical state that gives the wrapper its time-averaged meaning. The call only updatesreferenceSpotRateAddressand emits an event. It leavescurrentRateIndex,lastUpdate,currentCheckpointIndex,checkpointCountand all stored checkpoints untouched. That breaks the accounting model as soon as the source changes. BothupdateTWAPRate()andgetRateWindow()assume that the full interval sincelastUpdatebelongs to the current source: After a rotation, that assumption is false. The stored index and checkpoints still encode the old source, but the newly selected source is used to extend the index and to answer future window queries. The nextupdateTWAPRate()can therefore apply the new source's current rate across time that actually belonged to the old source andgetRateWindow()can report a blended average made from two unrelated histories while still returningisValid = true. This is not just a privileged-actor threat. A normal maintenance flow can trigger it. If operators deploy a corrected Morpho spot-rate adapter and migrate the wrapper to it, the wrapper does not start a clean new average. It immediately begins serving economically nonsensical data derived from both the old and new sources. Any market or downstream oracle that trusts this wrapper can price, settle, or mark positions off a corrupted rate for up to the full averaging window. This is relevant to the checked-in deployment topology. The deployment flow already creates Morpho-backedTWAPOracleinstances whose constructor source is a deployedReferenceRateMorpho. The deployment scripts do not currently callsetReferenceSpotRateAddress()themselves, so the bug is not triggered during initial deployment. The risk appears later, when operators use the exposed admin function to rotate a live wrapper to a replacement Morpho source during maintenance, migration, or recovery.Recommendation
Treat a source rotation as a new oracle history.
setReferenceSpotRateAddress()should either revert unless the wrapper is freshly deployed, or it should fully resetcurrentRateIndex,lastUpdateand the checkpoint ring buffer before initializing the replacement source. If continuity across rotations is required, add explicit migration logic that proves the old and new sources are economically identical and safely transforms the historical state instead of silently blending unrelated histories. -
M-12 Medium US10YR Pyth trades outside feed hours Unexpected Behavior Acknowledged
Description
The deployed
US10YRPythadapter only enforces an age check. That would be fine for a truly continuous feed, butPythdocuments itsRatesfeeds as session-based and notes that they are available only on weekdays from8:00 AMto5:00 PM ET. The Base deployment still givesPythAdapter_US10YRa four daymaxAge. Do note that the deployment script includes a market deployment path that picks upPythAdapter_US10YRand uses it as thereferenceRateOraclewhen the adapter is present. That means a quote from the previous Treasury session can remain acceptable far beyond the feed's active market hours. If aKairosmarket uses that adapter, the protocol can keep allowing entries, settlements, liquidations and LP repricing overnight or across weekend and holiday gaps using the last in-sessionUS10YRvalue. The oracle can still be fresh enough to satisfygetPriceNoOlderThan, but the quote is not current enough to serve as a fair executable market price because the underlying feed is outside its documented session. Once a market is wired to this adapter, the problem reaches trading, settlement, liquidation and LP repricing directly, which is why it is more than a low-severity ops caveat.Recommendation
Do not treat
maxAgeas a complete availability check for session-basedPythRatesfeeds. Add a market-hours guard for these feeds and block executable actions outside the supported session. At minimum, tighten theUS10YRadapter'smaxAgeso a previous-session quote cannot remain tradable through overnight or weekend closure windows. -
M-13 Medium L2 Sequencer Check Missing Oracle Resolved
Description
ChainlinkAdapter validates latestRoundData() responses and enforces a maxAge staleness check but does not check the L2 sequencer uptime feed or enforce a post-recovery grace period. If this adapter is used on Base, the protocol can accept stale or unsafe prices during sequencer downtime or immediately after recovery.
Recommendation
Add an L2 sequencer uptime and grace period check to ChainlinkAdapter.
-
M-14 Medium Shared LP vesting blocks unrelated withdrawals DoS Resolved
Description
Kairos prevents an LP address from depositing and withdrawing in the same block by recording
lastDepositBlockon the LP position. On its own, that is a reasonable anti-flash-loan guard. The problem starts onceKairosMarketAdapteris used as a shared Morpho liquidity adapter. WhenVaultV2auto-allocates deposits, the adapter supplies collateral to Kairos from its own address. That means every vault user shares the same LP identity inside Kairos. If a withdrawal later needs funds back from the adapter, the adapter exits through that same address. Because the lock is keyed by LP address rather than by vault user, a deposit by one account updateslastDepositBlockfor the entire pooled position and can make another account's withdrawal revert withE413in the same block. That turns a local anti-flash-loan rule into a cross-user griefing vector. An attacker only needs to watch for withdrawals that depend on adapter liquidity and front-run them with a dust deposit into the vault. The deposit refreshes the adapter's LP lock, the withdrawal tries to deallocate in the same block and the exit fails. Repeating that pattern can keep affected withdrawals failing block after block for very little cost. No funds are stolen directly, but users can be denied timely exits from an otherwise liquid position.Recommendation
Avoid wiring this adapter into a flow that auto-allocates every user deposit directly into Kairos. Keeping an idle buffer in the vault, or moving allocation to an asynchronous keeper or allocator step, avoids refreshing a shared LP lock immediately before user withdrawals. If synchronous allocation must remain, the vesting lock in Kairos needs to be scoped to something other than the adapter's single LP address, or the integration needs a withdrawal path that does not rely on unwinding a position that may have been touched earlier in the same block.
-
M-15 Medium Utilization Fees Can Be Reduced by Splitting Logical Error Resolved
Description
SwapFormulas.calculateUtilizationFee() prices utilization using the current trade’s size relative to the currently available liquidity, i.e. tradeSize / availableLiquidity, rather than the full utilization transition caused by the order. SwapCore.buySwap() uses this value directly when determining utilizationFee, which is then incorporated into swap pricing and buyer collateral. Because the fee is assessed independently for each swap against the liquidity available at that moment, a user can split one large order into smaller sequential swaps and pay materially less total utilization fee than if the same total notional were executed as a single trade. This weakens the intended economic effect of the kinked utilization curve, especially at higher utilization, where larger liquidity consumption is supposed to incur disproportionately higher fees. For example, with available liquidity of 1000 and a linear utilization-fee slope of 10%, a single swap of 100 pays a utilization-fee contribution of 1.0. Splitting the same order into two sequential swaps of 50 pays about 0.25 and 0.2632, for a total of 0.5132. The same final exposure therefore reduces utilization fees by roughly 48.7% simply by splitting the order.
Recommendation
Price utilization based on the full utilization movement caused by the order, rather than only the local tradeSize / availableLiquidity ratio of each individual swap.
-
M-16 Medium Zero Share Supply Can Trigger False Pool Reset Logical Error Resolved
Description
SwapCore.withdrawCollateral() can reduce a market’s LP share supply to zero even though the pool is not actually empty. That alone is not a problem, since remaining collateral may still be required to support existing swap state. The issue is that supplyCollateral() later uses pool.totalShares == 0 as the trigger for 1:1 bootstrap minting, treating the market as if it were a fresh pool. As a result, a new depositor can enter after the share supply has been reduced to zero and be minted shares as though they are the first LP, even though residual pool collateral or unresolved swap state may still remain. If that remaining state leaves value in the pool, the new depositor can capture value they did not properly pay for because zero share supply was treated as equivalent to an empty pool.
Recommendation
Do not use pool.totalShares == 0 alone as the condition for bootstrap minting. The protocol should ensure that 1:1 bootstrap minting is only possible when the pool is actually empty.
-
M-17 Medium Uninitialized buckets can return valid rates Validation Resolved
Description
getBaseRatedoes not verify that the buckets it reads were ever posted throughpostForecasts. If the global freshness check passes, the function can return an exact bucket that is not part of the active tenor set anymore, flat-extrapolate from an uninitialized edge bucket or interpolate against a zero-valued bucket withupdatedAt == 0. This creates silent bad pricing instead of a clean failure. In local reproduction, a curve with only the 365 day bucket posted returned0for a 90 day query withisValid = true, and a curve with only the 60 day bucket posted returned a non-zero interpolated rate against an uninitialized upper endpoint. The same exact-match shortcut also keeps serving forecasts for buckets that governance removed withsetTenorBuckets.Recommendation
Reject any quote unless every bucket used to produce it is both configured and initialized. For exact matches, require that the tenor is part of
tenorBucketsand hasupdatedAt != 0. For interpolation and extrapolation, require initialized endpoints and returnfalseif either side is missing. -
M-18 Medium Stale Buckets Can Pass Validation Oracle Resolved
Description
FixedRatePricingOracle.getBaseRate() checks freshness using only the most recently updated tenor bucket in the curve, rather than the specific tenor bucket or bucket pair used for the returned quote. Forecasts are likely intended to be updated as a full tenor curve in a single batch, but the contract does not enforce that. As a result, some tenor buckets can remain stale while another bucket is refreshed, and queries for the stale tenor can still return isValid = true. This is important because SwapCore trusts the oracle’s validity flag and uses the returned base rate directly for swap pricing, collateral calculations, LP mark-to-market pricing, and early-exit settlement. For example, if 7d and 30d are configured and only 30d gets refreshed, a later query for 7d can still return the stale 7d rate as valid. The protocol therefore continues operating with outdated pricing inputs instead of cleanly rejecting them.
Recommendation
Validate freshness for the actual bucket or bucket pair used to produce the quote. Exact-match queries should require the exact tenor to be fresh, interpolated quotes should require both surrounding buckets to be fresh, and extrapolated quotes should require the endpoint bucket used to be fresh. Alternatively, add checks in postForecasts() to enforce full-curve updates against the configured tenorBuckets, so partial refreshes cannot leave stale or uninitialized tenor points implicitly valid.
-
L-01 Low Bucket saturation inflates LP pricing gas Gas Griefing Resolved
Description
LP entry, LP exit and LP-adapter valuation all depend on
getPoolSharePrice, but that function scales with the configured bucket count rather than with a bounded constant-time accounting model. The market validator allows up to 1000 buckets, and the pricing path iterates over every bucket on each call. For each populated bucket it may also perform an externalgetBaseRatecall for a bucket-specific remaining tenor.if (params.numBuckets == 0 || params.numBuckets > 1000) revert Errors.E703();for (uint256 i = 0; i < market.numBuckets; i++) { Types.Bucket storage bucket = buckets[i]; if (bucket.lpNotional == 0) { continue; } uint256 remainingTenor = market.swapTerm > timeElapsed ? market.swapTerm - timeElapsed : 0; if (remainingTenor > 0) { projectedRate = getBaseRateOracle(market.baseSwapRateOracle, remainingTenor); } totalLpPnL += calculateBucketUnrealizedPnL(bucket, market, projectedRate, oracleIndex); }Because bucket assignment is deterministic and time-based, an attacker can keep one live swap in each bucket by opening small positions across successive intervals. Once enough buckets are populated, every call to
supplyCollateral,withdrawCollateral, andKairosMarketAdapter.realAssetsinherits the full loop and the full set of oracle calls. A standalone Forge POC was used to compare two otherwise identical 1000-bucket markets. In the sparse case with one populated bucket,getPoolSharePriceconsumed 2,415,994 gas. In the dense case with one live swap in every bucket, the same call consumed 14,031,500 gas. Using a shared gas cap of 8,223,747, the sparse market still priced successfully while the saturated market failed. That is a material attacker-controllable gas spike on a critical path. This does not yet prove a hard block-limit denial of service on current EVM block gas limits, so the impact is better characterized as gas griefing and degraded composability rather than outright uncallability. The risk is still real because these pricing calls sit on the main transaction path for LP actions and downstream adapter valuation, and the gas burden can become large enough to make integrations brittle or uneconomical.Recommendation
If these gas costs are not acceptable, consider not putting an unbounded bucket walk with external oracle calls on a critical onchain pricing path. The cleanest fix is to replace the current share-pricing model with incrementally maintained accounting that does not need to scan every bucket during user actions. If the bucket model must stay, reduce the maximum bucket count to a conservative gas-safe value and cache or precompute the required tenor rates so LP-facing operations do not suffer attacker-controlled gas inflation.
-
L-02 Low Adapters stale-price realAssets on oracle fail Oracle Resolved
Description
Both Kairos Morpho adapters can continue returning stale marks exactly when the underlying Kairos market has stopped accepting fresh pricing updates. The root cause is that
SwapCore._getFreshIndexcatches oracle failures and silently falls back to the stored rate index instead of propagating the error.function _getFreshIndex(address oracle) private view returns (uint256 idx) { idx = rateIndex.getIndex(oracle); uint256 elapsed = block.timestamp - rateIndex.getLastUpdate(oracle); if (elapsed > 0) { try IRateOracle(oracle).getRate() returns (int256 r, bool ok) { if (ok) { int256 rateWad = Utils.normalizeToWadSigned(r, IRateOracle(oracle).decimals()); idx = SwapFormulas.calculateNewRateIndex(idx, rateWad, elapsed); } } catch {} } }KairosBuyerAdapter.realAssetsrelies ongetSwapNetAmount, andKairosMarketAdapter.realAssetsrelies ongetPoolSharePrice. Both of those views ultimately use_getFreshIndex, so they keep returning values based on the last stored index when the oracle is stale, invalid, or reverting. That behavior diverges from the actual state-changing Kairos paths, which userateIndex.updateand revert on invalid oracle data.(int256 currentRate, bool isValid) = IRateOracle(oracle).getRate(); require(isValid, "Oracle rate invalid");As a result, an external vault can continue using adapter
realAssetsfor deposits, withdrawals, or share conversion while the underlying market itself has already halted on oracle failure. This creates a window where vault users trade against stale NAV precisely during oracle incidents, which is when pricing error is most dangerous.Recommendation
Do not let adapter NAV continue updating from stale state once the oracle has failed. The simplest fix is to make the valuation path used by
realAssetsrevert under the same conditions as the state-changing Kairos path, instead of silently reusing the last stored index. If a stale fallback is intentionally kept for monitoring or read-only UX, surface that condition explicitly and make sure parent vault integrations can detect it and stop deposits, withdrawals, and share pricing until the oracle is healthy again. -
L-03 Low Vault V2 cannot recover adapter escrow Unexpected Behavior Resolved
Description
When a buyer-side settlement or liquidation transfer fails,
SwapCoredoes not keep the payout insettlementPayouts. Instead it escrows the amount underescrowedCollateral[swapId]and zeroes the released payout value. That fallback is correct at the core protocol level, butKairosBuyerAdapteris not wired to recover it through the normal Vault V2 adapter interface. The adapter's settled deallocation path only checkssettlementPayouts, forwards that amount to the vault, and then removes the swap from internal tracking. If the settlement transfer was escrowed,settlementPayoutsis zero, so the adapter reports no recovered funds, returnschange = 0, and can still prune the swap even though the real proceeds are still sitting insideSwapCore.if (settled) { uint256 payout = swapCore.settlementPayouts(sid); if (payout > 0) asset.safeTransfer(parentVault, payout); _removeSwapId(mid, sid); change = -int256(payout); return (ids, change); }The adapter does expose a dedicated
claimEscrowfunction, but that function isonlyParentVault. The problem is that standard Morpho Vault V2 integrations do not route arbitrary adapter-specific helper calls. The normal adapter surface isallocate,deallocate, andrealAssets. SinceSwapCore.claimEscrowpays only the recorded swap owner, external users cannot bypass the adapter and recover the escrow directly either. For adapter-owned swaps, the adapter remains the only effective owner, yet the standard vault interface does not provide a path to make that recovery happen.function claimEscrow(bytes32 swapId) external onlyParentVault returns (uint256 claimed) { claimed = swapCore.claimEscrow(swapId); if (claimed > 0) { asset.safeTransfer(parentVault, claimed); } }Consider a realistic Morpho Vault V2 deployment where Alice deposits USDT into the vault and Bob, acting as allocator, uses
KairosBuyerAdapterto buy a Kairos swap on the vault's behalf. The important point is not that USDT omits the standard boolean return value ontransfer, because the escrow helper already treats empty return data as success. The relevant risk is that USDT retains administrative controls such as pause and blacklist behavior. When the swap later settles in Alice's favor,SwapCoretries to return the buyer proceeds to the adapter because the adapter is the recorded swap owner. If the USDT transfer reverts because the token is paused or becauseSwapCorehas been blacklisted as a sender, the proceeds are moved intoescrowedCollateral[swapId]instead. Bob then performs the normal vault-side recovery step and callsdeallocatefor the settled swap. The adapter seessettlementPayouts[swapId] == 0, forwards nothing back to the vault, returnschange = 0, and removes the swap from its tracking set. At that point Alice's funds still exist insideSwapCore, but neither Alice nor Bob can retrieve them through the standard Vault V2 workflow even though the vault was the economic owner of the position. More broadly, the bug does not depend on USDT specifically or on any claim that escrow is a common path. The protocol intentionally implements escrow as a supported fallback for failed buyer transfers. Once that fallback exists, every integration that takes custody of swap ownership also needs a standard way to recover escrowed proceeds and reflect that recovery in vault accounting. Otherwise the system is explicitly acknowledging that funds may be redirected into escrow while leaving one of its intended integrations unable to complete the recovery flow. The result is that escrowed buyer proceeds can become practically stranded in a standard Vault V2 deployment. Even if a custom vault or privileged wrapper later adds a bespoke path to callclaimEscrow, the adapter still does not report the recovered amount through the ordinarydeallocateaccounting contract, so vault allocation and cap state remain stale after recovery. This is therefore both a recoverability failure and an integration-accounting failure.Recommendation
Escrow recovery needs to be exposed through the standard adapter interface. A straightforward fix is to add a dedicated
deallocateaction that claims escrow, forwards the recovered funds to the vault, and returns the appropriate negativechange. The adapter should also avoid removing settled swaps from tracking while eithersettlementPayoutsorescrowedCollateralremains nonzero. If the integration intends to support escrow at all, recovery cannot depend on an adapter-specific helper that standard Vault V2 does not know how to call. -
L-04 Low Buyer adapter realAssets can DoS valuation DoS Resolved
Description
KairosBuyerAdapter.realAssets()performs a full scan over every tracked buyer swap and recomputes mark-to-market value one position at a time. The function first loops over all active markets, then over all tracked swap ids in each market, then callsswapCore.getSwapNetAmount()for every unsettled swap. That means the gas cost of vault valuation grows linearly with the number of live buyer positions held through the adapter.function realAssets() external view override returns (uint256 assets) { assets = asset.balanceOf(address(this)); uint256 length = marketIds.length; for (uint256 i = 0; i < length; i++) { bytes32[] memory swapIds = _marketSwapIds[marketIds[i]]; for (uint256 j = 0; j < swapIds.length; j++) { (, , bool settled, , uint256 collateralBalance, , , , , , , , ) = swapCore.swapPositions(swapIds[j]); if (!settled) { (uint256 netAmount, uint8 netRecipient) = swapCore.getSwapNetAmount(swapIds[j]); ... } } } }This is not a cheap inner call either.
getSwapNetAmount()derives a fresh floating-leg mark using the current simulated oracle index, so each per-swap iteration performs additional pricing work instead of reading a cached balance. As the vault accumulates more fragmented buyer exposure,realAssets()becomes more and more expensive. Morpho uses adapterrealAssets()on valuation-critical paths. That means deposits, withdrawals, redemptions, previews, accrual, and cap logic can all inherit this unbounded per-swap walk. An allocator can naturally create many live positions over time, and a malicious or careless strategy can intentionally fragment exposure into many small swaps. Once enough swaps exist, routine vault operations can become prohibitively expensive or revert under gas limits. This is a liveness issue on the vault integration path, not just an inefficient view. Consider a realistic deployment where Alice deposits into a Morpho vault that usesKairosBuyerAdapterfor rate exposure, and Bob is the vault's approved allocator. Bob does not need to do anything exotic to trigger the problem. He can simply implement a rolling strategy that opens many small swaps instead of a few large ones so that entries mature at different times or target slightly different rate views. After weeks or months of normal operation, the adapter may be tracking a large set of live buyer positions across one or several Kairos markets. At that point, when Alice callspreviewWithdraw,withdraw, or even when a new depositor checkspreviewDeposit, the vault has to walk all of those swaps throughrealAssets(). The valuation work scales with historical position fragmentation rather than with a bounded adapter state, so ordinary vault actions can become too expensive or revert even though no single Kairos position is individually problematic. This issue is distinct from the existing LP pricing gas finding. That one scales with configured bucket count in LP share pricing. This one scales with the raw number of live buyer swaps held by the adapter, so even a single-market deployment can still degrade badly if the strategy opens enough positions.Recommendation
Do not make vault valuation scale with live swap count. The safest fix is to maintain aggregate buyer valuation state on write and have
realAssets()read a bounded amount of cached data rather than scanning every position. If exact real-time marking is too expensive to maintain, use a conservative cached value and update it during allocate, deallocate, settlement, liquidation, and escrow-recovery events. At minimum, cap the number of simultaneously tracked swaps per adapter before using it in a vault strategy whose core user flows depend onrealAssets(). -
L-05 Low Factory deploys unscoped market adapter Unexpected Behavior Resolved
Description
KairosMarketAdapterFactorypresents itself as a safe one-adapter-per-vault deployment path, but the adapter it deploys is not scoped to any fixed Kairos market. The factory stores a single adapter per vault invaultToAdapter, which gives the impression that the vault is being connected to a specific reviewed strategy surface. In reality, the deployedKairosMarketAdapteraccepts an arbitrarymarketIdon everyallocatecall, tracks that market dynamically and returns only a single globaladapterIdback to Morpho for cap accounting.function createAdapter(address vault, address asset) external returns (address adapter) { if (vaultToAdapter[vault] != address(0)) revert AdapterAlreadyExists(); ... adapter = address(new KairosMarketAdapter(vault, swapCore, asset)); ... vaultToAdapter[vault] = adapter; }if (data.length == 32) { marketId = abi.decode(data, (bytes32)); } else if (data.length == 64) { (marketId, minSharesOut) = abi.decode(data, (bytes32, uint256)); } ... swapCore.supplyCollateral(marketId, assets, address(this), 0); ... ids = new bytes32[](1); ids[0] = adapterId; change = int256(assets);That design collapses all Kairos market exposure for a vault into one Morpho accounting bucket even though the underlying Kairos markets can differ materially in oracle design, maturity profile, whitelist settings, collateralization, risk premium source and owner control. The problem is amplified by the fact that
SwapCore.createMarketis permissionless. A new market with the same asset can be created at any time and immediately becomes reachable by the already-deployed adapter, without any new factory action and without any new Morpho cap domain. A realistic example is a USDC vault where Alice deposits after Bob, the curator, adds the official Kairos LP adapter and sets what he believes is a conservative Morpho cap for a single reviewed 7 day market. Weeks later, Carol creates a second USDC Kairos market with a fragile or owner-controlled oracle. Bob's allocator key is then compromised, or Bob's strategy script simply selects the wrongmarketId. Because the adapter is unscoped and always reports the sameadapterId, the vault can move Alice's capital into Carol's market without violating the existing Morpho cap. From the vault's point of view nothing unusual happened at the cap layer, but economically Alice is now exposed to a completely different and potentially much riskier market than governance intended. This is not just a monitoring inconvenience. It removes the main isolation boundary that Morpho caps are supposed to provide. A vault curator can no longer constrain exposure per Kairos market once this factory path is used, even though market-level risk varies in ways that are highly relevant to LP safety.Recommendation
The adapter deployed by the factory should be market-scoped, not protocol-scoped. The cleanest fix is to deploy one adapter per Kairos market and make the market identifier immutable in the adapter constructor. If multi-market support is still required, the adapter should at minimum enforce an immutable allowlist of market ids and return a market-specific id, not just a single global
adapterId, so Morpho caps and disable controls can operate at the same granularity as the actual Kairos risk surface. -
L-06 Low Collateralization ratio runs backwards Unexpected Behavior Resolved
Description
The protocol exposes
collateralizationRatioas if a larger value means a market is safer and more heavily collateralized, but the implementation does the opposite. Both buyer collateral and LP collateral are divided bycollateralizationRatio, while available liquidity and maximum leverage increase with it. In other words, raising the ratio makes the market less collateralized and more levered even though the name and surrounding comments strongly suggest the reverse.SwapCore.createMarket()describes the parameter as the ratio of LP collateral required per unit of notional exposure, and market validation allows values up to10e18. A market creator, frontend, or curator reading that interface would reasonably expect2e18to mean 200 percent collateralization and therefore stricter limits. In reality,2e18halves required collateral and doubles the liquidity the pool will advertise against the same capital base. Consider a realistic case where Bob creates a permissionless USDC market and promotes it as conservative because it uses a2e18collateralization ratio. Alice sees the parameter, sees the usual wording around collateral, and assumes the market is safer than a1e18market. She supplies liquidity, and other users buy swaps against it. Under the hood, however, the pool is allowing roughly twice as much notional exposure per unit of collateral as Alice expects, and buyers are posting only half the collateral she thinks they are. A rate move that should have been comfortably absorbed by an overcollateralized market can now consume the pool far faster because the market was configured in the opposite direction from what the interface advertised.Recommendation
The implementation and the public semantics need to be brought back into alignment. The best fix is to make the math match the name: if
collateralizationRatiois supposed to represent more required collateral when it is larger, the collateral and liquidity formulas should be inverted accordingly and the accepted range should be reviewed around that meaning. If the current math is intentional, then the parameter should be renamed everywhere to reflect what it actually is, such as an inverse collateral factor or leverage factor, and the creation path should reject values that are likely to be misread. The safer option is to fix the formulas and validation rather than preserving a misleading parameter name. -
L-07 Low Full withdraw mode breaks vault pull flow Validation Resolved
Description
KairosMarketAdapter.deallocate()has two different modes. In partial-withdraw mode, the vault passes a concreteassetsamount and later pulls that same amount from the adapter. In full-withdraw mode, the adapter is supposed to send the realizedwithdrawnamount to the vault itself, which only works safely ifassets == 0. The problem is that the contract only enforces one side of that rule. It rejectsassets == 0for partial withdrawals, but it does not rejectfullAmount == truewithassets > 0. That breaks the Morpho pull flow. In full-withdraw mode the adapter may already have pushedwithdrawnto the vault, butVaultV2still performs its normalsafeTransferFrom(adapter, vault, assets)afterdeallocate()returns. Ifassetsis nonzero, the vault tries to pull funds a second time even though the adapter was using the push-transfer path. The outcome is a revert. The adapter sends the unlocked collateral to the vault, then the vault tries to pullassetsfrom the adapter and fails because the adapter no longer has that balance. If the adapter happens to hold unrelated idle funds, the vault can pull those instead, which is even worse because the call succeeds with the wrong accounting.Recommendation
The adapter should explicitly revert when
fullAmount == trueandassets > 0. That check should happen before any external call. This is the smallest fix and it keeps the two deallocation modes aligned with Morpho's expectations. If the team wants to keep a custom all-assets unwind path, it would be cleaner to expose it separately instead of overloading the standarddeallocate()flow with two incompatible transfer models. -
L-08 Low isSwapActive is true for unknown ids Warning Resolved
Description
isSwapActivedoes not check whether the swap exists. The root cause is the whole function:function isSwapActive(bytes32 swapId) external view returns (bool) { return !swapPositions[swapId].settled; }For an unknown mapping key, Solidity returns the default struct. In that default struct
settledisfalse, so the function returnstruefor a swap that was never created. Any offchain service that usesisSwapActiveas an existence check will treat arbitrary ids as live swaps unless it adds its own second check.Recommendation
Return false when the stored swap has
entryTimestamp == 0, or revert for unknown ids. Either choice is better than conflating "missing" with "active." -
L-09 Low Unset multisig disables market creation fees Unexpected Behavior Resolved
Description
SwapCore.createMarkettreats the market-creation fee as configured as soon asCREATE_MARKET_FEE_AMOUNTis nonzero, but it only validates, emits and transfers that fee inside branches that also requireadmin.getProtocolMultisig() != address(0). If the amount is set whilePROTOCOL_MULTISIGis still unset, the function proceeds to create both markets and simply skips fee collection entirely. This is inconsistent with the protocol-fee path.Admin.setProtocolFeeEnabledexplicitly refuses to enable protocol fees until a nonzero multisig has been configured, but the market-creation fee setters have no equivalent guard. As a result, the protocol can appear to have market-creation fees enabled while market creation is actually free. The deployment flow configures the market-creation fee token and amount, then logs that fees will be sent toadmin.getProtocolMultisig(), but it does not configure that recipient in the same flow. In that state, anti-spam assumptions around paid market creation fail silently and any user can continue creating markets without paying the intended fee.Recommendation
Require a nonzero fee recipient whenever market-creation fees are configured or charged. The simplest fix is to make
createMarketrevert ifCREATE_MARKET_FEE_AMOUNT > 0andadmin.getProtocolMultisig() == address(0). It would also be safer to add the same invariant at configuration time so setting a nonzero market-creation fee amount is impossible until the recipient has already been set. -
L-10 Low Protocol fee timelock can be bypassed Unexpected Behavior Resolved
Description
Adminonly timelocks the numeric value ofPROTOCOL_FEE_RATE. The owner can queue a nonzero rate throughqueueProtocolFeeRate, wait outFEE_RATE_TIMELOCK, activate that rate throughactivateProtocolFeeRateand still leaveprotocolFeeEnabledset to false. At that point the fee is armed but not yet charged. The actual switch that makes users start paying issetProtocolFeeEnabledand that function is immediate.SwapCore.buySwapreads the liveAdminstate at execution time. It performs the buyer's slippage check on the swap rate first, then separately computesprotocolFeefromadmin.getProtocolFeeEnabled()andadmin.getProtocolFeeRate(), adds that fee intototalBuyerRequiredand pulls the full amount from the buyer:if (rateBound != type(int256).max) { if (market.rateType == Types.RateType.BUY_FIXED) { if (swapRate > rateBound) revert Errors.E510(); } else { if (baseRate < rateBound) revert Errors.E510(); } } uint256 protocolFee = 0; if (admin.getProtocolFeeEnabled()) { uint256 feeRate = admin.getProtocolFeeRate(); if (feeRate > 0) { protocolFee = (notionalAmount * feeRate * swapTerm) / (SwapFormulas.SECONDS_IN_YEAR * SwapFormulas.WAD); } } uint256 totalBuyerRequired = requiredBuyerCollateral + protocolFee; IERC20(swapToken).safeTransferFrom(msg.sender, address(this), totalBuyerRequired);Because
rateBoundnever constrainsprotocolFeeortotalBuyerRequired, a buyer has no onchain way to say "execute only if fees are still off" or "execute only if the protocol fee is below X". An owner can therefore pre-arm a fee rate, watch pendingbuySwaptransactions and frontrun them withsetProtocolFeeEnabled(true). If the buyer has enough balance and allowance, the swap still succeeds but pays an extra unexpected fee. If not, the transaction reverts. The issue is amplified by the wider protocol because market creation does not capswapTerm, so the same annualized fee rate can become a large upfront charge on long-dated markets.Recommendation
Timelock the whole protocol-fee configuration rather than only the numeric rate. At minimum, enabling fees should be queued and activated under the same delay as the rate itself so users get reliable notice before charging begins. It would also be safer to let buyers bound fee exposure directly, for example by adding a
maxProtocolFeeormaxTotalBuyerRequiredparameter tobuySwap, so governance cannot change fee charging between quote time and execution without giving users a way to reject the trade. -
L-11 Low minTradeSize comment contradicts validation Validation Resolved
Description
SwapCore.createMarket documents minTradeSize as “0 = no minimum,” but the actual validation path in Utils.validateMarketParams requires minTradeSize > 0. Since createMarket always calls _initializeMarket, which calls Utils.validateMarketParams, any attempt to create a market with minTradeSize = 0 will revert.
Recommendation
Align code and docs. Either allow minTradeSize = 0 by removing the > 0 require, or update the code comment (and any external docs) to explicitly state minTradeSize must be greater then zero.
-
L-12 Low SwapClosed emits zero collateralBalance Events Resolved
Description
The protocol exposes
SwapClosedas the unified close event for expiry, early exit and liquidation and that event includes a field namedcollateralBalance. The problem is that this field is emitted after the settlement and liquidation helpers have already zeroed the underlying storage slot. The normal settlement path makes this explicit.Utils.settleSwap()settles the net payment, prepares any buyer collateral release and then clears the buyer-side collateral before control returns toSwapCore:if (swap.collateralBalance > 0) { collateralToRelease = swap.collateralBalance; buyerCollateralReleased = collateralToRelease; swap.collateralBalance = 0; }After that,
SwapCore._settleSwaps()emitsSwapClosedand passesswap.collateralBalancedirectly into the event:emit SwapClosed( marketId, swapId, swap.userAddress, msg.sender, swap.isEarlyExit ? uint8(1) : uint8(0), buyerCollateralReleased, lpCollateralReleased, amountSettled, totalPaymentsMade, netRecipient, floatingPayment, fixedPayment, swap.collateralBalance, earlyExitFee );The liquidation path does the same thing.
Utils.liquidateSwap()zeroesswap.collateralBalancebeforeSwapCore.liquidateSwap()emits the unified close event, so liquidated positions also publish a zerocollateralBalanceeven when buyer collateral originally existed. This is not dead metadata in the repo as the indexer storesSwapClosed.collateralBalanceon the unifiedswapsentity the frontend then trusts that stored value for settled positions:collateralBalance: isSettled ? BigInt(swapEntity.collateralBalance || 0) : BigInt(swapEntity.buyerCollateral || 0),The portfolio page then aggregates that settled
collateralBalanceinto user metrics:const collateralBalance = BigInt(swap.collateralBalance || 0); settledCollateral += collateralBalance;As a result, closed positions are systematically reported with zero collateral in offchain views, even though users may care about the original deployed margin or about residual value that was released or escrowed at close. This can mislead portfolio views, buyer-vault accounting displays and any operator dashboard that relies on the unified event stream instead of re-reading full onchain state.
Recommendation
Do not emit
swap.collateralBalanceafter the settlement helpers have zeroed it. If the protocol wants to expose pre-close buyer collateral, capture that amount in a local variable before clearing storage and emit that value instead. If the intended meaning is something else, rename the field so consumers do not interpret it as meaningful remaining collateral. Offchain consumers should also stop treating the currentSwapClosed.collateralBalancevalue as settled collateral until the event payload is corrected. -
L-13 Low Market creation ignores oracle validity Validation Resolved
Description
SwapCore._initializeMarket()callsgetBaseRate()andgetRate()to validate the supplied oracles, but it only checks that the calls do not revert. It ignores the returnedisValidbooleans. That means a market can be created with an oracle that is already stale or invalid, as long as the adapter reports that state withfalseinstead of reverting.ChainlinkAdapteris one example: a stale feed returns(0, false), but still passes market creation. In theChainlinkAdapterflow, the stale oracle passedcreateMarket(), the first LP deposit in the creation block succeeded and then later actions started failing once the protocol actually enforced oracle validity. So the protocol can create a market, accept funds and only afterwards reveal that the oracle was never usable.Recommendation
Require the returned boolean to be
trueduring market creation. A non-reverting call should not be treated as proof that the oracle is healthy.try IRateOracle(params.referenceRateOracle).getRate() returns (int256 rate, bool ok) { require(ok, "referenceRateOracle invalid"); } catch { revert("referenceRateOracle must implement IRateOracle"); }Apply the same check to the base rate and optional risk premium oracles.
-
L-14 Low Deploy script wires TWAP as Base-Rate oracle Warning Acknowledged
Description
The Base market deployment script builds AAVE and COMPOUND market configs by pairing the live reference-rate adapters with
TWAPOracle_*contracts asbaseRateOracle.if (AAVE_ORACLE && AAVE_TWAR) { markets.push({ referenceRateOracle: AAVE_ORACLE, baseRateOracle: AAVE_TWAR, name: "AAVE", }); } if (COMPOUND_ORACLE && COMPOUND_TWAR) { markets.push({ referenceRateOracle: COMPOUND_ORACLE, baseRateOracle: COMPOUND_TWAR, name: "COMPOUND", }); }That wiring is incompatible with
SwapCore. Market creation explicitly requiresbaseSwapRateOracleto implementIBaseRateOracle.getBaseRate(...).try IBaseRateOracle(params.baseSwapRateOracle).getBaseRate(params.swapTerm) returns (int256, bool) {} catch { revert("baseSwapRateOracle must implement IBaseRateOracle"); }But
TWAPOracleonly implementsIRateOracle.contract TWAPOracle is Ownable2Step, IRateOracle {So the AAVE and COMPOUND market-creation calls in this script should revert during
_initializeMarket(). The script then catches the error, logs it and continues. The result is not a direct security exploit, but it is still a meaningful deployment defect: operators can run the script expecting those markets to be created, while the intended markets are silently skipped from the final deployment state.Recommendation
Make the deployment wiring match the oracle interfaces that
SwapCoreenforces. If the second oracle is meant to be a tenor-dependent base-rate source, pass a contract that implementsIBaseRateOracle, such asImpliedBaseRateor another adapter with a realgetBaseRate(...)entrypoint. IfTWAPOracleis intended to fill that role, it needs a compatiblegetBaseRate(...)interface before it is used in market creation. The script should also fail when a configured market cannot be created, instead of catching the error and moving on as though the deployment was largely successful. -
L-15 Low Bounded early exit breaks on escrow fallback Logical Error Resolved
Description
SwapCore.exitSwapEarly()advertisesminExitAmountas the buyer's slippage guard for early exit. The problem is that the guard is enforced after settlement againstsettlementPayouts, while the protocol's failed-transfer fallback writes buyer proceeds into escrow and zeroes the exact value thatsettlementPayoutsmirrors. The flow inexitSwapEarly()is:results = _settleSwaps(swapIds); for (uint256 i = 0; i < requests.length; i++) { if (requests[i].minExitAmount == 0) continue; uint256 buyerPayout = settlementPayouts[swapIds[i]]; if (buyerPayout < requests[i].minExitAmount) revert Errors.E552(); }Inside
_settleSwaps(), the recorded payout is taken directly frombuyerCollateralReleased:settlementPayouts[swapId] = buyerCollateralReleased;But
Utils.settleSwap()clears that value whenever the payout had to be escrowed instead of transferred immediately:bool transferred = _transferOrEscrow(IERC20(swapToken), swap.userAddress, collateralToRelease, swapId, escrowedCollateral); if (!transferred) { buyerCollateralReleased = 0; }That means the slippage guard is really checking "how much was synchronously delivered to the recipient address" rather than "what economic exit value was realized for the buyer." If the recipient cannot currently receive the token and the protocol would otherwise fall back to escrow,
settlementPayoutsbecomes zero, theminExitAmountcheck fails and the whole early-exit transaction reverts. The escrow fallback is therefore unavailable precisely when the caller uses a nonzero slippage bound.Recommendation
Do not enforce
minExitAmountagainst onlysettlementPayoutsafter the transfer attempt. The bound should be checked against the economic buyer value realized by settlement before payout delivery is attempted, or against a value that includes escrowed proceeds when the transfer fails. In other words, slippage protection should measure exit economics, not synchronous token delivery. As a practical fix, compareminExitAmountto the computed buyer-side payout before_transferOrEscrow, or preserve a separatebuyerPayoutDuevalue that remains nonzero even when the payout is redirected into escrow. -
L-16 Low Shared liquidityData breaks small withdrawals Unexpected Behavior Resolved
Description
VaultV2stores a singleliquidityDatapayload for its liquidity adapter and reuses that exact same bytes value for both automatic allocation during deposits and automatic deallocation during withdrawals. That design is incompatible withKairosMarketAdapter, because the adapter gives the second encoded word different meanings on entry and exit. On allocation, a 64-byte payload is decoded as(marketId, minSharesOut), so the second word acts as a deposit slippage guard. On deallocation, the same 64-byte payload is decoded as(marketId, minCollateralOut), so that same number becomes a minimum withdrawal amount instead.if (data.length == 64) { (marketId, minSharesOut) = abi.decode(data, (bytes32, uint256)); }if (data.length == 64) { (marketId, minCollateralOut) = abi.decode(data, (bytes32, uint256)); }This means a vault configured with
abi.encode(marketId, minSharesOut)can accept deposits normally and still become unable to process healthy small withdrawals. Whenever the vault needs to pull liquidity from the adapter, it forwards the same payload intodeallocateand the former deposit slippage value is enforced asminCollateralOut. If the requested withdrawal from Kairos is smaller than that number,SwapCore.withdrawCollateralreverts even though the market is liquid and the share price is healthy. In practice, this makes the adapter's 64-byte deposit slippage mode unsafe when the adapter is used as Morpho's liquidity adapter. A more conservativeminSharesOutimproves deposit protection but also increases the chance that ordinary user exits fail until governance or an allocator manually changes the shared configuration.Recommendation
Do not reuse one shared
liquidityDatablob for two different parameter meanings. The cleanest fix is to makeKairosMarketAdapterinterpret the payload consistently on allocation and deallocation, or to disable the 64-byte slippage mode when the adapter is wired intoVaultV2as the liquidity adapter. If separate slippage controls are required for deposits and withdrawals, the integration should expose separate configuration for allocate and deallocate data instead of relying on one shared bytes payload. -
L-17 Low PythAdapter Confidence Interval Not Validated Oracle Resolved
Description
PythAdapter.getRate retrieves the Pyth price struct but never reads pythPrice.conf, the confidence interval field. Pyth publishes a confidence interval alongside every price to quantify uncertainty. During volatile market conditions, this interval can widen significantly, meaning the reported price may deviate from the true market rate. Pyth’s documentation recommends rejecting prices where the confidence interval is too wide relative to the price itself. Without this check, the adapter can pass high‑uncertainty prices into any downstream logic that consumes the oracle rate.
Recommendation
Reject or bound prices when pythPrice.conf is too large relative to pythPrice.price (e.g., enforce a max confidence ratio or use conservative bounds), in line with Pyth’s confidence‑interval guidance.
-
L-18 Low BUY_FLOATING slippage guard ignores markup Unexpected Behavior Resolved
Description
SwapCore.buySwap()exposesrateBoundas slippage protection, but on theBUY_FLOATINGpath it only checksbaseRate. The deterministic markup that is actually charged at entry,utilizationFee + riskPremium, is computed separately and is not covered by the bound. That creates a quote-to-execution gap. A prior trade can reduce available liquidity and pushutilizationFeeup, or the risk-premium oracle can move higher, whilebaseRatestays above the user's bound. The transaction still succeeds, but the buyer gets a more expensiveBUY_FLOATINGposition than expected.KairosBuyerAdapter.allocate()forwards the samerateBound, so vault integrations inherit the same problem.Recommendation
For
BUY_FLOATING, the slippage check should cover the full entry cost, not justbaseRate. Let callers cap either the total markup (utilizationFee + riskPremium) or the final all-in rate charged at execution. Any adapter should pass through and enforce the same bound. -
L-19 Low Swap transfer depends on oracle freshness Unexpected Behavior Resolved
Description
transferSwapPosition()only changes who owns an active swap, but it still forces a liverateIndex.update()on the market's reference oracle before the transfer can complete.swap.userAddress = newOwner; rateIndex.update(markets[swap.marketId].referenceRateOracle);That pricing dependency is unnecessary for a pure custody move. No swap terms change, no collateral moves, and no settlement value is realized. If the reference oracle is stale, invalid, or temporarily reverting, the ownership transfer fails anyway. This turns an oracle outage into a custody freeze. The impact reaches more than direct transfers because
SwapPositionWrapperusestransferSwapPosition()inside bothwrap()andunwrap(). For example imagine the reference oracle goes unhealthy for a few hours while a user is trying to move a live position into the wrapper for downstream use, or trying to unwrap it so they can send it to a rescue wallet. Nothing about that action needs a fresh rate, but the transfer still reverts because the function insists on updating the index first. The user is stuck with the current custody layout until the oracle recovers. Therefore, the protocol is blocking ownership mobility in a path that should remain available even when pricing functions are paused by an oracle problem.Recommendation
Remove the oracle update from
transferSwapPosition(). The custody transfer should not depend on live oracle availability when no economic state is being repriced. If the protocol still wants an explicit refresh hook, keep it as a separate call or perform it only in functions that actually consume pricing or settlement state. If some freshness bookkeeping must remain near transfers, make it non-blocking so an unhealthy oracle cannot trap users in the wrong custody path. -
L-20 Low Duplicate tenors can bypass maxRateChangeWad Validation Resolved
Description
postForecastsdoes not reject duplicate entries intenors. The function writes each item immediately, so the same tenor can be updated several times in one transaction. Each step is compared only against the value written in the previous loop iteration, not against the rate that existed before the batch started. That makes the rate-change breaker weaker than it looks. A batch like[6%, 7%, 8%]for the same tenor will pass a1%breaker if the previous stored rate was5%, even though the final rate moved by3%in one call.Recommendation
Reject duplicate tenors in
postForecasts. The safest change is to require each active tenor to appear exactly once per batch. If partial updates remain allowed, at minimum keep a per-call seen set and revert on duplicates before any writes happen. -
L-21 Low Interpolation can mix different forecast epochs Unexpected Behavior Resolved
Description
postForecastsaccepts any subset of tenor buckets and writes each bucket independently. The contract does not track a curve-level snapshot or epoch, so adjacent buckets can come from different posting runs while still looking fresh enough to use together. This weakens the integrity of interpolated quotes. A 75 day rate can be built from one endpoint posted in an older model run and another endpoint posted in a newer run, even though those values were never meant to describe the same curve. That can distort pricing whenever the off-chain engine updates the curve incrementally.Recommendation
Treat each forecast curve as a single snapshot. The smallest safe change is to store and validate a per-post epoch or batch timestamp, then require both interpolation endpoints to share that epoch before returning a quote. If partial updates are not needed, require every configured tenor bucket to be posted atomically in one call.
-
L-22 Low Last LP full withdraw leaves residual collateral Warning Resolved
Description
SwapCore.withdrawCollateral()roundsfullAmountwithdrawals down from the current share price:sharePrice = getPoolSharePrice(marketId); if (fullAmount) { sharesToRedeem = lpPos.shares; withdrawalAmount = Math.mulDiv(sharesToRedeem, sharePrice, WAD); }It then burns all redeemed shares and subtracts only that rounded amount from
pool.totalCollateral:lpPos.shares -= sharesToRedeem; pool.totalShares -= sharesToRedeem; pool.totalCollateral -= withdrawalAmount;When the withdrawing LP is the last shareholder and the pool share price is above
1e18, this double rounding can leave a small remainder inpool.totalCollateraleven thoughpool.totalShareshas been reduced to zero. The issue is not limited to direct calls intoSwapCore.withdrawCollateral(). It is also reachable through the vault adapter flow becauseKairosMarketAdapter.deallocate()forwardsfullAmount=truewithdrawals into the same core function. That breaks the protocol's own empty-pool assumptions.getPoolSharePrice()returns1e18whenevertotalShares == 0, so the leftover collateral is masked rather than reconciled. The dust is small, but it is still incorrect accounting. It can remain stranded indefinitely if the market is never reinitialized, or be transferred to the next LP that becomes the first depositor after the pool is reopened. Because the same logic is used by adapter-mediated exits, the bug affects both direct LP withdrawals and Morpho vault deallocations.Recommendation
When a
fullAmountwithdrawal burns the last outstanding LP shares, the withdrawal path should not leave residual collateral behind. The smallest safe fix is to detect the last-shareholder case and pay out the entire remaining unlocked collateral, or equivalently add any rounding remainder towithdrawalAmountbefore zeroing the pool. The implementation should preserve the invariant thatpool.totalShares == 0impliespool.totalCollateral == 0andpool.lockedCollateral == 0after a successful unwind. Add regression coverage for both a directwithdrawCollateral(fullAmount=true)path and theMorphoVault.deallocate -> KairosMarketAdapter.deallocate -> SwapCore.withdrawCollateral(fullAmount=true)path. -
L-23 Low calculateMaxLeverage under-scales WAD output Documentation Resolved
Description
SwapFormulas.calculateMaxLeverage()is documented to return a leverage ratio "in WAD precision":* @return Maximum leverage ratio (unitless, in WAD precision)But the implementation never multiplies the numerator by
WAD:return (SECONDS_IN_YEAR * collateralizationRatioWad) / (effectiveRate(swapRateWad) * term);That means any valid leverage below
1e18truncates to0instead of returning a nonzero WAD-scaled ratio. WithswapRateWad = 4.400359769748619533e18,term = 25261170, andcollateralizationRatioWad = 1.622899438354856419e18, the function returns0even though the correctly scaled result is about4.604e17. This helper is only exercised by the test wrappers and the implemented fuzzing suite, so the immediate impact is limited to incorrect leverage reporting and broken assumptions in any offchain or future onchain consumer that treats the result as WAD-scaled. The bug is still real because the code and NatSpec disagree and the function currently returns a value in the wrong units.Recommendation
Make the implementation match its declared units by applying
WADscaling in the numerator:return Math.mulDiv(SECONDS_IN_YEAR * collateralizationRatioWad, WAD, effectiveRate(swapRateWad) * term); -
L-24 Low Liquidity Routing Can Block Deposits DoS Resolved
Description
Vault deposits can be temporarily blocked when the configured liquidity adapter route points to a market that cannot currently accept LP deposits. For example, SwapCore.supplyCollateral() reverts when sharePrice == MIN_SHARE_PRICE and lockedCollateral > 0, and that failure propagates through KairosMarketAdapter into the vault deposit path. This is notable because KairosMarketAdapter can manage multiple markets, so deposits may still be valid for other markets, yet deposits are still blocked as long as the vault remains configured to route through the unavailable one. The same issue can also arise if the configured market is terminated or the adapter path otherwise becomes unusable.
Recommendation
Be aware that this can temporarily block deposits and may require manual intervention to update or clear the liquidity adapter configuration.
-
I-01 Informational Buyer factory accepts counterfeit vaults Trust Assumptions Resolved
Description
KairosBuyerAdapterFactory.createAdapter()is presented as a factory for Morpho Vault V2 adapters, but it does not actually verify that thevaultargument is a real Morpho vault. It only checks that the address has code, thatasset()returns the expected token, and that the caller matchesowner()orcurator(). Any contract can be written to satisfy those checks. A fake vault only needs to expose the same small surface and return values that make the factory happy. Once that happens, the factory deploys a realKairosBuyerAdapterfor it and emits the sameAdapterCreatedevent it would emit for a genuine Morpho vault. That is the whole problem. The factory looks like a source of truth for "official Kairos buyer adapters for Morpho vaults", but in practice it is only a deployer for any contract that imitates the expected interface. As soon as one counterfeit vault passes the check, factory provenance stops being a reliable legitimacy signal. For example, imagine that Bob deploys a fake vault contract that returns USDC fromasset()and returns Bob fromowner()andcurator(). He then calls the official buyer-adapter factory and gets a real adapter plus a real factory event. Alice later sees that adapter on a dashboard or in an indexer that trusts the factory address and assumes it belongs to a genuine Morpho vault. The factory has now helped Bob produce something that looks official even though the underlying vault was never authenticated.Recommendation
The factory should verify that
vaultcomes from a canonical Morpho source of truth instead of trusting a loose interface check. The best fix is to require that the vault was deployed by the approved Morpho vault factory or is listed in an authoritative Morpho registry. If that is not available, a strict codehash or version check is still better than the current behavior. If the team intentionally wants a permissionless deployment path for arbitrary vault-like contracts, that path should be separated and clearly named so the main factory and its events still mean what users and integrators expect them to mean. -
I-02 Informational Unused totalNotionalReduction in settleSwap Informational Resolved
Description
Utils.settleSwap declares uint256 totalNotionalReduction = 0; and conditionally assigns it to swap.notionalAmount when pool collateral backing is released, but the variable is never read, returned, or used to update state.
Recommendation
Remove the unused variable, or, if the notional reduction logic was intended, update the relevant state explicitly.
-
I-03 Informational ImpliedBaseRate lets users manipulate pricing Logical Error Acknowledged
Description
ImpliedBaseRateis not an external market rate. It is calculated from current protocol liquidity, so the same user who is about to trade can first move liquidity and then immediately consume the new rate. The protocol still treats that value like a neutral oracle input in LP pricing, swap entry, and early-exit settlement.uint256 correspondingAvailableLiquidity = SwapFormulas.calculateAvailableLiquidity( swapCore.totalLPAvailableCollateral(crsMarketId), referenceRate, market.swapTerm, market.collateralizationRatio ); uint256 currentAvailableLiquidity = SwapFormulas.calculateAvailableLiquidity( swapCore.totalLPAvailableCollateral(currentMarketId), referenceRate, currentMarket.swapTerm, currentMarket.collateralizationRatio );Once that rate is exposed through
Utils.getBaseRateOracle(), it reaches every pricing path that matters.Utils.calculateTotalUnrealizedPnL()andUtils.calculateLpSharePrice()use it to mark LP positions, whileSwapCore.supplyCollateral()andSwapCore.withdrawCollateral()mint and burn shares against the price observed before balances are updated.SwapCore.buySwap()uses the same rate to lock swap terms and size collateral, and the early-exit branch in_settleSwaps()reads it again to project the value of the remaining leg. For example imagine a trader holds a position they want to exit early. Before calling the exit path, they first add or remove liquidity in the paired market used byImpliedBaseRate. That shifts the reported base rate without any real change in the outside market. The trader then exits while_settleSwaps()reads the distorted rate. BecauseUtils.calculateEarlyExitSettlement()uses that projected rate to value the remaining payments, the trader can make the exit cheaper or more favorable and push the loss onto passive LPs. The same idea applies on entry: move liquidity first, then buy the swap at a better coupon or collateral requirement. It also applies to LP shares: mint or burn at the old share price, then let the same liquidity move reprice the pool immediately after. This is worse in an early exit because the projected rate is not only used to size remaining payments. It can also decide whether those projected payments are counted at all.bool projectedFavorsBuyer = (market.rateType == Types.RateType.BUY_FIXED && remainingFloatingPayment > remainingFixedPayment) || (market.rateType == Types.RateType.BUY_FLOATING && remainingFixedPayment > remainingFloatingPayment); if (projectedFavorsBuyer) { totalFixedPayment = accruedFixedPayment; totalFloatingPayment = accruedFloatingPayment; } else { totalFixedPayment = accruedFixedPayment + remainingFixedPayment; totalFloatingPayment = accruedFloatingPayment + remainingFloatingPayment; }LP share pricing and swap pricing both become functions of mutable protocol liquidity instead of an external rate. If markets use
ImpliedBaseRatehere, users can create their own repricing window and extract value from passive LPs. This is not just an unused code path. The current deployment set in this repository already includes deployedImpliedBaseRatecontracts on Base and the active protocol integration setup also creates a market that usesImpliedBaseRateas itsbaseSwapRateOracle.Recommendation
Do not allow
ImpliedBaseRateto be used anywhere the protocol needs a manipulation-resistant base rate.buySwap(), LP share pricing and early-exit settlement should read an exogenous or time-lagged rate that cannot be changed by the same user in the same pricing window. If market imbalance is meant to affect pricing, compute that adjustment insideSwapCorefrom a snapshot of pre-action state and apply it explicitly as part of the transaction, instead of routing it through an oracle that depends on live balances. As an immediate safeguard, rejectImpliedBaseRateasbaseSwapRateOraclefor markets that support LP mint/burn or early exit until the pricing flow is redesigned. -
I-04 Informational US10YR cranker pays for unchanged Pyth updates Oracle Resolved
Description
The Base oracle cranker treats
US10YRTreasuryas stale whenever the onchain PythpublishTimeis more than ten minutes old and the scheduler runs every minute. That creates a bad interaction with a session-based feed. Outside Treasury hours, Hermes keeps returning the same last-session payload and the onchain Pyth contract keeps the same last-sessionpublishTime. After ten minutes, the cranker starts deciding that every run needs an update even though there is nothing new to submit. The write path then still fetches the Hermes binary, paysgetUpdateFeeand callsupdatePriceFeeds(...). Pyth explicitly allowsupdatePriceFeedsto succeed even when the payload is not newer. The function meant to avoid this,updatePriceFeedsIfNecessary(...), is not used. The result is repeated paid no-op writes throughout nights, weekends and holidays. This is live-reachable on Base. There are currently two unpaused markets usingPythAdapter_US10YRas theirreferenceRateOracleand the latest US10YR payload currently costs0.0000015 ETHto submit. The immediate effect is wasted keeper spend. The more important effect is that the updater wallet gets drained faster, which increases the chance that a later real Pyth update is missed and the market falls into the separate stale-oracle failure mode already logged.Recommendation
Do not trigger Pyth writes from age alone. Before sending an update, compare the latest Hermes
publish_timeagainst the current onchainpublishTimeand skip the transaction unless the offchain payload is newer. The best fix is to replace the currentupdatePriceFeeds(...)call withupdatePriceFeedsIfNecessary(...), passing the feed id and Hermes publish time. For session-based feeds such asUS10YR, the keeper should also stop treating off-hours as a reason to send updates at all. -
I-05 Informational Use minimal proxies for buyer adapters Gas Optimization Acknowledged
Description
Every call to
KairosBuyerAdapterFactory.createAdapterdeploys a full newKairosBuyerAdapterinstance withnew KairosBuyerAdapter(...), then runs constructor logic to setparentVault,swapCore,asset, computeadapterIdand issue two max approvals. That full bytecode deployment is the main gas cost of theKairosBuyerAdapterFactorycontract`. A minimal proxy or clone-with-immutable-args design would replace full adapter deployments with a much smaller proxy deployment plus initialization, cutting the cost of each new adapter materially.Recommendation
Refactor
KairosBuyerAdapterFactoryto deploy a shared implementation through an EIP-1167 proxy or a clone-with-immutable-args pattern. SinceKairosBuyerAdaptercurrently relies on constructor-set immutables and constructor-time approvals, the practical options are to move setup into a one-timeinitializeflow, or to adopt a clone pattern that keeps immutable args while still handling approvals during initialization. -
I-06 Informational Avoid copying swapId arrays in buyer views Gas Optimization Resolved
Description
KairosBuyerAdapter.realAssets()andKairosBuyerAdapter.collateralInMarket()both load_marketSwapIds[marketId]into a memory array before iterating it. That forces a full storage-to-memory copy of every tracked swap id array on each call, even though the functions only need sequential read access. This is unnecessary overhead on the adapter's hottest read paths.realAssets()is especially sensitive because it already walks every tracked buyer position and is part of the vault-facing valuation surface. Replacing the memory copy with a storage reference and a cached length removes the array materialization cost while preserving behavior. Gas savings: deterministic savings on every non-empty market iteration in both view functions, with the benefit scaling linearly with the number of tracked swaps per market.Recommendation
Iterate the storage array directly instead of copying it to memory first. A minimal pattern is:
bytes32[] storage swapIds = _marketSwapIds[marketId]; uint256 swapLen = swapIds.length; for (uint256 j; j < swapLen; ) { bytes32 swapId = swapIds[j]; ... unchecked { ++j; } }Apply the same change to both
realAssets()andcollateralInMarket(). -
I-07 Informational Add known-settled credit helper in wrapper Gas Optimization Resolved
Description
Several single-token flows in
SwapPositionWrapperalready know the position is settled before they try to credit proceeds.settle()checks the position and may settle it itself,exitEarly()settles before crediting, andredeem()explicitly verifies settlement. Even so, all of them route through_creditIfNeeded(), which callsswapCore.swapPositions()again just to re-check a fact the caller already established. A focused benchmark in a temporary Foundry harness measured this exact pattern on a settled single-token path. The current version averaged49,346gas, while a version that switched to a_creditKnownSettled(...)helper averaged47,148gas. That is a saving of2,198gas per call. Gas savings: about2,198gas per call on known-settled paths such assettle(),exitEarly(), andredeem().Recommendation
Split the current helper into two paths: keep
_creditIfNeeded()for callers that genuinely do not know settlement status and add a_creditKnownSettled(tokenId, swapId)variant for call sites that already verified or caused settlement. That removes one redundant externalswapPositions()read from each of those flows. -
I-08 Informational Reuse updated index in LP share price path Gas Optimization Acknowledged
Description
Both
supplyCollateral()andwithdrawCollateral()update the reference-rate index and then immediately go through the share-price path. At that point no time has elapsed, sogetPoolSharePrice()just re-reads the same stored index through_getFreshIndex()before passing it intoUtils.calculateLpSharePrice(...). A focused benchmark measured this pattern at32,713gas in its current form and30,993gas when the fresh index fromupdate()is passed directly intoUtils.calculateLpSharePrice(...), for a saving of1,720gas per call. Gas savings: about1,720gas persupplyCollateral()orwithdrawCollateral()call on this path.Recommendation
In
supplyCollateral()andwithdrawCollateral(), keep the value returned byrateIndex.update(...)and pass it straight intoUtils.calculateLpSharePrice(...)instead of immediately routing back throughgetPoolSharePrice(). -
I-09 Informational Cache swapPositions in settleBatch Gas Optimization Resolved
Description
SwapPositionWrapper.settleBatch()currently re-readsswapCore.swapPositions(swapId)more often than needed. For an uncredited token, the function can hitswapPositions()once in the counting loop, again in the array-fill loop and then a third time through_creditIfNeeded()during the final credit loop. A focused benchmark in a temporary Foundry harness measured a representative 10-token batch where every token was uncredited and unsettled. The current flow averaged932,348gas, while a one-scan version that cachedtokenIdandswapIdin memory, batch-calledmakePayment()and credited directly without re-reading settlement status averaged889,541gas. That is a saving of42,807gas for the batch, or about4,280gas per token on this path. Gas savings: about42,807gas for a 10-token all-unsettled batch, or about4,280gas per token. The saving scales with batch size.Recommendation
Refactor
settleBatch()into a single discovery pass. Cache the relevanttokenIdandswapIdpairs in memory, collect the unsettledswapIds formakePayment()and then credit the cached positions directly instead of routing back through_creditIfNeeded()and re-checking settlement. -
I-10 Informational Cache fresh rate index in buySwap Gas Optimization Resolved
Description
SwapCore.buySwap()callsrateIndex.update(referenceRateOracle), then ignores the returned index and readsrateIndex.getIndex(referenceRateOracle)twice to populateentryFloatingIndexand theSwapCreatedevent. Those reads are redundant becauseupdate()already returns the fresh value. A focused benchmark measured this exact pattern at50,115gas in its current form and48,761gas with the cached-index version, for a saving of1,354gas per call. Gas savings: about1,354gas perbuySwap()call.Recommendation
Store the return value of
rateIndex.update(referenceRateOracle)in a local variable and reuse it for both the struct field and the event instead of callingrateIndex.getIndex(referenceRateOracle)again. -
I-11 Informational PR #544 fix leaves post-expiry repricing open Warning Resolved
Description
PR #544 adds historical snapshots and uses them in
getSwapNetAmount()and settlement, but it does not apply the same rule to LP mark-to-market.calculateBucketUnrealizedPnL()still derives the floating leg from the liveoracleIndexwith uncappedtotalElapsed, so expired-but-unsettled LP exposure keeps moving when the oracle changes after maturity.SwapCore.getPoolSharePrice()still routes through this path, so the branch does not fully close M-01. This is not just a stale view path. The same share price is used bysupplyCollateral(),withdrawCollateral()andKairosMarketAdapter.realAssets(), so live LP deposits, withdrawals and downstream valuations can still be priced off post-expiry oracle movement. The branch also makes expiry freezing best-effort instead of deterministic.RateIndexLib.getIndexAt()returns0when there is no post-target snapshot,SwapCore.getSwapNetAmount()then falls back to the legacy_getFreshIndex()path, andUtils.settleSwap()likewise falls back to current-index pricing whenexpiryIndex == 0. That means both preview and settlement are only fixed when historical reconstruction succeeds. The preview fallback still feedsKairosBuyerAdapter.realAssets(), so buyer-side mark-to-market can remain drift-prone until someone callsupdateMarketRateIndex()after expiry. The newOracleIndexDriftTestfile only exercises the fixed path after explicitly seeding that update, so this window is not covered by the branch tests. One more sign that the fix stopped short is in the LP pricing code itself. The comment incalculateBucketUnrealizedPnL()still says the uncappedtotalElapsedpath "matches settlement." That was true before the refactor, but it is no longer true once settlement switches to expiry-index pricing. The stale comment is consistent with the code: the settlement path was updated, while the LP mark-to-market path was left behind. There is also a second edge case in the new snapshot design. Bitmap scans are capped at roughly 35 days, butswapTermis not similarly bounded. A dormant or long-dated market can therefore drop back to the legacy path instead of resolving an expiry index.Recommendation
Apply the expiry-snapshot rule to every post-expiry pricing path, not just swap preview and settlement.
getPoolSharePrice()and the bucket mark-to-market logic should resolve the floating leg from the expiry index and capped duration once a bucket is past maturity. Remove the silentexpiryIndex == 0fallback from user-facing mark-to-market paths, or replace it with a deterministic rule that cannot reintroduce live-oracle repricing. If bounded bitmap scans are kept, enforce a matching maximum supported horizon or store enough checkpoint data to guarantee lookup for the full supportedswapTerm.
Remediation Review 1
15 findings · April 2 to 10, 2026-
M-01 Medium Cumulative markets still call getRate() Logical Error Resolved
Description
createMarket()acceptsRateConvention.CumulativeandRateIndexLib.initialize()validates that mode throughgetCumulativeIndex(), butbuySwap()still unconditionally callsUtils.getOracleRate(referenceRateOracle)before any market-type branching. The shipped cumulative adapters undercontracts/oracles/cumulativeIndex/implementgetCumulativeIndex()anddecimals(), notgetRate(), so a market can be created successfully and then revert on the first buy. The same assumption is repeated in the quote and implied-rate paths:int256 referenceRate = Utils.getOracleRate(market.referenceRateOracle);That leaves cumulative markets in a half-initialized state where the advertised oracle mode is accepted at creation time but not supported by execution-time code paths.
Recommendation
Split the pricing oracle from the settlement index oracle. For cumulative markets, never call
getRate()on the reference oracle unless a separate spot-pricing oracle is configured for that purpose. Any path that only needs the cumulative index should usegetCumulativeIndex()exclusively. -
M-02 Medium Aave borrow index grows with zero debt Unexpected Behavior Acknowledged
Description
CumulativeIndexBorrowAaveassumesgetReserveNormalizedVariableDebt(asset)always represents borrow interest that has actually accrued on Aave. That is not true when the reserve has no variable debt. In that state, Aave can still return a higher projected debt index based on the current borrow rate and elapsed time, even though its stored borrow index does not move because there are no borrowers paying variable interest. Kairos uses this adapter as a cumulative floating-rate source. If the linked Aave reserve spends time with zero variable debt, Kairos can keep accruing floating interest that never existed on the source market. That misprices the floating leg and can shift value between LPs and buyers at mark-to-market and settlement.Recommendation
Do not use
getReserveNormalizedVariableDebt()on its own. The adapter should also check whether the reserve currently has non-zero variable debt. If debt is zero, it should return the stored borrow index instead or mark the oracle invalid. If that cannot be implemented safely, this adapter should only be used for reserves where non-zero variable debt is guaranteed as an operating assumption. -
M-03 Medium Morpho borrow share price is not cumulative Unexpected Behavior Acknowledged
Description
CumulativeIndexBorrowMorphoreports Morpho's borrow share price as if it were a monotone cumulative index:uint256 index = (borrowAssets + VIRTUAL_ASSETS) * 1e27 / (borrowShares + VIRTUAL_SHARES);That assumption is wrong. Morpho interest accrual grows
totalBorrowAssetswhile leavingtotalBorrowSharesunchanged, but normalborrow()andrepay()flows also changetotalBorrowShareswith asymmetric rounding. Upstream Morpho rounds borrow-share minting up and repay-share burning down. Those rounding steps can make the reported share price move down across legitimate market activity even when the underlying borrow rate is non-negative. That breaks the contract's cumulative-oracle promise. Kairos accepts this value throughRateIndexLibas realized floating accrual and later annualizes index changes withSwapFormulas.deriveRateFromIndex(). Once the reported "cumulative" index can fall, ordinary Morpho borrower activity can make Kairos infer a negative floating rate from a market that never had a negative borrow rate in the first place.Recommendation
Do not use Morpho borrow share price as a
RateConvention.Cumulativeoracle. If Morpho borrow exposure is needed, use a spot-rate integration and compound it in Kairos, or build an adapter around a truly monotone realized borrow index instead of raw borrow-share price. -
M-04 Medium Expiry pricing includes post-expiry moves Unexpected Behavior Acknowledged
Description
RateIndexLib.getIndexAt()reconstructs a historical index by interpolating between the nearest stored snapshots aroundtargetTime. That is only exact if the index path between those checkpoints is the one the protocol wants to price. In settlement-critical flows that assumption does not hold.SwapCore._settleSwaps()first callsrateIndex.update(...), which appends a new snapshot at the current settlement time and only then resolvesexpiryIndex = rateIndex.getIndexAt(..., actualExpiry). If the book was quiet and there was no checkpoint near expiry, the reconstructed expiry index is pulled toward that late post-expiry snapshot.getSwapNetAmount()and LP expiry pricing rely on the same historical lookup once such a late snapshot exists. That means the current design does not fully freeze value at maturity. It removes the old unbounded "use the live index" bug, but a delayed first post-expiry checkpoint can still leak post-expiry rate movement back into the supposed expiry price. With an entry snapshot at 5%, a first post-expiry snapshot one term later after the oracle has moved to 50%, the reconstructed expiry rate lands near the midpoint instead of the true expiry level. Settlement and mark-to-market then price the floating leg off a contaminated expiry index.Recommendation
Do not treat
getIndexAt()as exact for settlement-critical expiry pricing unless the protocol guarantees a checkpoint close to expiry. The smallest safe fix is to store an explicit expiry snapshot when a swap or bucket crosses maturity, or otherwise resolve expiry only from pre-expiry data. If that cannot be guaranteed, settlement-critical paths should only proceed when the available snapshots pin down expiry closely enough. Otherwise, the protocol should wait for a reliable expiry checkpoint instead of estimating expiry from a much later post-expiry update. -
M-05 Medium LP Gate Triggers Before Expiry Logical Error Resolved
Description
The expired-unsettled LP gate is keyed to a coarse expiry epoch rather than the true swap expiry timestamp. When a swap is opened, buySwap() records expiryEpoch = floor((entryTimestamp + swapTerm) / bucketInterval). Later, _revertIfExpiredUnsettled() treats the market as blocked once expiryEpoch * bucketInterval <= block.timestamp. That means the gate activates at the start of the bucket containing expiry, not at the actual maturity time of the earliest unsettled swap. If a position is opened near the end of a bucket, LP deposits and withdrawals can be blocked for almost a full bucketInterval before any swap has actually expired. For example, if swapTerm = 1 day and bucketInterval = 1 hour, a swap opened at 12:59:59 expires at 12:59:59 the next day, but the gate begins blocking at 12:00:00. For the next 59 minutes and 59 seconds, supplyCollateral() and withdrawCollateral() reverts even though no expired swaps that can be settled exist. The impact is that protocol actions are blocked earlier than intended. LPs can be prevented from depositing or withdrawing even though no swap has actually matured yet, and any integration that depends on those flows inherits the same premature denial of service.
Recommendation
Modify the LP gate to use the earliest exact unsettled swap expiry, not the floored expiry epoch boundary. This ensures supplyCollateral() and withdrawCollateral() remain available until a swap has actually matured.
-
M-06 Medium LP adapter reverts on last-share withdraw Unexpected Behavior Acknowledged
Description
KairosMarketAdapterassumes a partialwithdrawCollateralcall returns exactly the requestedassets, so it leavesVaultV2in pull mode for that amount afterdeallocatereturns. That assumption is false when the adapter is the last LP in a market with active swaps. In that caseSwapCore.withdrawCollateralcan keep one share alive and reduce the actual withdrawal amount even thoughfullAmountis false.if (pool.totalShares == sharesToRedeem) { if (pool.lockedCollateral > 0) { sharesToRedeem -= 1; withdrawalAmount = Math.mulDiv(sharesToRedeem, sharePrice, WAD); } }The adapter then holds less than
VaultV2tries to pull withsafeTransferFrom, sodeallocatereverts. This breaks partial allocator withdrawals in a stressed market. It also breaksforceDeallocate, because that function reuses the same pull-after-deallocate behavior whileKairosMarketAdapterblocksfullAmount = true. An allocator can still fall back tofullAmount = truethrough the normaldeallocatecall, but the permissionless emergency unwind is still lost in this state.Recommendation
Handle the realized amount instead of assuming the requested amount is always available on partial withdrawal. The safest fix is to switch to a push-based transfer whenever
withdrawn != assets, or add a dedicated deallocation mode that reports and transfers the actualwithdrawnamount back to the vault. -
M-07 Medium Bucket PnL nets before swap caps Logical Error Acknowledged
Description
Final settlement is capped per swap. LP gains on a given swap cannot exceed that swap’s buyer collateral, and LP losses cannot exceed that swap’s pool collateral backing. The bucket MtM path does not preserve those per-swap caps. In calculateBucketUnrealizedPnL(), the protocol first computes one net bucket PnL from averaged bucket parameters and only then caps that net result by the bucket’s aggregate collateral totals. That is not equivalent to summing individually capped swap outcomes. If one swap in the bucket is already far enough in LP profit that its payoff should be saturated at its own buyer-collateral cap, while another swap in the same bucket is LP-lossmaking, the bucket model can still let the first swap’s uncapped excess offset the second swap before any cap is applied. Real settlement cannot do that, because the profitable swap would have been capped first. For example, suppose two swaps are in the same bucket. Swap A would give LP +200, but its buyer collateral cap is 100, so realizable PnL is +100. Swap B would give LP -50, fully realizable. The true total is therefore +50. But the bucket model can instead net first and produce uncapped bucket PnL of +150. If the aggregate buyer collateral in the bucket is at least 150, that full +150 survives the bucket cap even though actual realizable settlement is only +50. Since bucket result feeds directly into getPoolSharePrice(), LP pricing and the Morpho LP adapter’s realAssets() can diverge from realizable per-swap settlement.
Recommendation
The key is to keep the per-swap collateral cap in place before positions are netted for any LP valuation that affects accounting. Right now the bucket model blends swaps together too early, so a single cap at the end is no longer the same as what would happen when those swaps actually settle. One way to fix that is to make the aggregation more selective, so only swaps with similar payoff and cap behavior are grouped together before netting and capping. If that still leaves too much approximation, the better option is to stop using this bucket mark-to-market path for values that need to match real settlement, such as LP share price and adapter asset reporting, and use a more conservative valuation there instead.
-
M-08 Medium Mixed-expiry bucket misprices LP MtM Logical Error Resolved
Description
The LP share-price path decides whether a bucket is active or expired from the bucket’s average entry time instead of from each swap’s true expiry. In _resolveBucketExpiryIndices(), the protocol computes avgEntryTime = bucket.weightedEntryTime / bucket.lpNotional, then sets expiryTime = avgEntryTime + swapTerm, and only treats the bucket as expired once the current timestamp passes that single average-expiry timestamp. That assumption is false whenever swaps inside the same bucket were opened at different times. In that case, some swaps can already be expired while others are still active, but the share-price path still forces the whole bucket into a single lifecycle state. For example, if swapTerm = 30 days and bucketInterval = 1 day, two swaps can enter the same bucket 23 hours apart. Their real expiries are also 23 hours apart. During the window after the earlier swap has expired but before the bucket’s average expiry is reached, the whole bucket is still treated as active. The already-expired subset therefore continues to follow the live oracle and active-bucket logic even though those swaps should already be frozen at their own expiry. The opposite window also exists on the other side of average expiry, where the still-active subset can be frozen too early. The same root issue persists even after all swaps in the bucket are expired. Once the bucket is treated as expired, _resolveBucketExpiryIndices() still resolves only one historical floating index at the bucket’s average expiry time and applies it to the entire bucket. If swaps inside that bucket actually expired at different times and the floating index moved between those expiries, exact settlement would freeze each swap at its own expiry index, but the LP MtM path instead freezes all of them at one intermediate average-expiry index. As a result, getPoolSharePrice() can diverge from exact per-swap pricing during mixed expired/active bucket states, and any path that relies on that share price inherits the same accounting error.
Recommendation
Do not determine bucket lifecycle from avgEntryTime + swapTerm. Buckets that can contain swaps with different expiries need to track expired and active subsets separately, or the protocol must avoid using bucket-level MtM for accounting-critical pricing during mixed-expiry states.
-
M-09 Medium Liquidatable swaps ignore liquidator haircut Logical Error Partially resolved
Description
calculateBucketUnrealizedPnL()caps LP mark-to-market against the full posted collateral inbucket.totalBuyerCollateralorbucket.totalPoolBacking. That is no longer the realizable value once a swap is already liquidatable. At that pointisSwapLiquidatable()has moved the economic cutoff down tocollateral - liquidatorRewardorpoolBacking - liquidatorReward, andliquidateSwap()pays that missing slice to the liquidator instead of the winning side. Consequently,getPoolSharePrice()can still overstate pool NAV during the liquidation window. LP minting, burning and any downstream integration that trusts LP share price can trade against value that is no longer collectible by the pool.Recommendation
Once a swap is liquidatable, share pricing should stop treating the full posted collateral as collectible. Cap its value at the amount the winning side could actually recover after the liquidation incentive is deducted.
-
L-01 Low Epoch pointer can clear too early Unexpected Behavior Resolved
Description
_advanceEpochPointer()assumes the next non-empty expiry epoch must appear withinswapTerm / bucketIntervalsteps. That assumption is false once swaps with later expiries have been opened long after the earliest expiry. If the earliest epoch is cleared after a long delay, the scan can hitmaxIterbefore it reaches the next populated epoch and will resetearliestUnsettledEpoch[marketId]to zero. Once the pointer is zeroed,_revertIfExpiredUnsettled()no longer blocks LP deposits and withdrawals even though expired unsettled swaps still exist. The contract comments say those operations must be blocked while expired swaps remain unsettled because share price may be unreliable, so clearing the pointer early defeats the safety gate exactly when it is supposed to be active.Recommendation
Track non-zero expiry epochs with an ordered data structure instead of a bounded linear scan. If a scan is kept, it must continue until the next populated epoch is found rather than clearing the pointer after an arbitrary hop count.
-
L-02 Low Donated funds cannot be swept at zero allocation Warning Resolved
Description
Both Morpho adapters expose a sweep action intended to recover donated or accidentally transferred tokens from the adapter. However, under the Morpho VaultV2, every deallocation goes through deallocateInternal(), which reverts if the adapter ID’s current recorded allocation is zero. As a result, if tokens are transferred directly to an adapter while Morpho records caps[id].allocation == 0, the intended sweep path cannot be used immediately. The vault reverts before the adapter’s returned change is applied, so the donation cannot be recovered through ACTION_SWEEP unless the adapter later has non-zero recorded allocation.
Recommendation
If this behavior is acceptable, explicitly document that ACTION_SWEEP cannot recover donated funds while the adapter has zero recorded allocation, so operators are aware of the edge case. Otherwise, add a separate recovery path for accidental token transfers that does not depend on Morpho’s deallocate flow.
-
L-03 Low Buyer cleanup can fail on unrelated bad oracle DoS Resolved
Description
KairosBuyerAdapter.deallocate()always recomputeschange = _allocationDelta(_realAssets())before it returns. That is too strict forACTION_CLAIM_ESCROWandACTION_REMOVE_SETTLED. Those actions only need to forward already-realized funds or prune bookkeeping for a settled swap. They do not need fresh pricing for the rest of the portfolio. The final_realAssets()call still walks every remaining unsettled swap and callsswapCore.getSwapNetAmount()on each of them. That valuation depends on healthy oracle reads. If some other tracked live swap is tied to a stale or invalid oracle, the cleanup transaction reverts even though the swap being cleaned up is already settled. Consequently, one unhealthy live position can block escrow recovery or removal of an unrelated settled swap.Recommendation
Do not require a full live portfolio valuation to complete
ACTION_CLAIM_ESCROWorACTION_REMOVE_SETTLED. Let those cleanup actions return using the last reported allocation when fresh MtM is unavailable, then resync allocation later. Another valid option is to separate bookkeeping cleanup from NAV synchronization so an unhealthy oracle on one live position cannot freeze unrelated recovery work. -
L-04 Low Market creation fee can change before create Trust Assumptions Resolved
Description
setMarketCreationFeeTokenandsetMarketCreationFeeAmounttake effect immediately.SwapCore.createMarketis permissionless and reads those values fromAdminat execution time before it charges the caller. The creator does not provide any expected fee token or maximum fee amount in calldata. Consequently, the owner can watch pendingcreateMarkettransactions and front-run them with a fee increase or a token switch. If the caller has enough ETH or ERC20 allowance for the new configuration, the market is still created but pays an unexpected fee. The live read happens inSwapCore.createMarket, which loadsCREATE_MARKET_FEE_AMOUNTandCREATE_MARKET_FEE_TOKENand then transfers the configured fee to the multisig in the same call.Recommendation
Do not let market-creation fee changes apply immediately to pending permissionless creates. The safest fix is to timelock
setMarketCreationFeeTokenandsetMarketCreationFeeAmountso creators get reliable notice before charging changes take effect. It would also be safer to let callers bound fee exposure directly. For example,createMarketcan accept an expected fee token and amaxFeeAmount, then revert if the live config is different at execution time. -
I-01 Informational Spot-compound projection can revert Math Partially resolved
Description
indexFromCumulativeRateTimeCompound()derives the live index withexpWad(crt / SECONDS_IN_YEAR).expWad()reverts once the exponent reaches its documented upper bound. That makesSpotCompoundRatemarkets depend on an unstated operating limit for the accumulatedrate * timeterm. Ordinary APRs should stay well inside the safe range, but a sufficiently high configured rate, or a long enough stale period under an unusually large rate, can push the exponent over the limit and make fresh-index reads revert. The affected paths are settlement-critical becauseRateIndexLib.update()andSwapCore._getFreshIndex()both depend on this projection forSpotCompoundRate.Recommendation
Treat the exponent ceiling as a protocol constraint instead of a hidden math limit. Add an explicit guard before calling
expWad(), and either clamp to a safe upper bound or revert with a domain-specific custom error that points operators to an invalid oracle configuration. If very large compounded rates must be supported, use an index representation that does not depend on a single exponential call over an unbounded accumulator. -
I-02 Informational Expired swap can freeze LP mobility Unexpected Behavior Acknowledged
Description
buySwap()does not check whether the market already has an expired unsettled swap. At the same time,supplyCollateral()andwithdrawCollateral()both revert once_revertIfExpiredUnsettled()sees an expired head-of-queue swap. Consequently, a small expired position can force LPs to clear queue maintenance before they can move capital, while new buyer trades remain available against the market's remaining liquidity. This is the case since settlement is permissionless but unrewarded. If the expired swap is economically negligible, nobody has a strong reason to spend gas settling it. The result is an asymmetric liveness failure: LPs lose immediate mobility, but buyers can still keep opening new swaps becausebuySwap()has no equivalent guard. This does not permanently trap funds because any caller can settle the expired swap and unblock LP actions. The issue is that the protocol lets a cheap expired position externalize cleanup cost onto LPs while keeping one side of the market open.Recommendation
Make the market-wide expired-unsettled rule consistent. Consider gating
buySwap()with the same_revertIfExpiredUnsettled()check used by LP deposits and withdrawals. If buyer trading should remain available, add a direct incentive or automatic cleanup mechanism so expired queue heads do not rely on altruistic settlement.
Main Review 2
46 findings · May 5 to 19, 2026-
H-01 High Direct settlement stale-prices vault shares Validation Resolved
Description
SwapCore.makePayment()is public and settles any supplied swap id. It does not distinguish ordinary user-owned swaps from swaps whose buyer is a Vault V2 adapter. Therefore any account can directly settle an adapter-owned Kairos swap without going through the vault allocator or the adapter'sdeallocate()logic.KairosBuyerAdapter._realAssets()values open buyer swaps by readingSwapCore.getSwapNetAmount()and adding or subtracting the current mark-to-market amount from posted buyer collateral. Once the swap is settled, the same position is no longer valued as an open swap. The adapter instead counts the tokens it actually received, escrowed collateral, or zero if the settlement consumed the buyer collateral. This means a direct settlement can immediately move adapter NAV from a pre-settlement mark to a lower or higher realized value. Morpho Vault V2 caches the first total-assets value used in a transaction. AfteraccrueInterest()records that first value, later share mints or burns in the same transaction can still be priced from the stale snapshot. A shareholder can callaccrueInterest(), directly settle a loss-making adapter-owned swap throughSwapCore.makePayment(), then redeem at the pre-loss share price. The mirror case also exists: a depositor can settle a gain-making adapter-owned swap and mint shares at the pre-gain share price. The value that can be extracted scales with the stale NAV gap and the attacker's share ownership. In the Base fork PoC below, the attacker captures about 30% of a1,572,406.998739 USDCstale NAV gap because they own about 30% of the Morpho vault shares at redemption time. POC The current POC is:forge test --match-path test-foundry/zz_POCs/DirectSettlementCurrentLocalPOC.t.sol -vvvvThe test is a Base fork PoC. It uses real Base USDC at
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913and a real Morpho Vault V2, Gauntlet USDC Prime at0x050cE30b927Da55177A4914EC73480238BAD56f0. It also uses the standard Base deployment oracle/config addresses fromdeployments/base: Morpho reference rate oracle0x2775289dEb423980AD2D7344850089Fb7F23963F, Morpho USDC/cbBTC fixed-rate forecast oracle0x3b964A9820E5781C57D9809b521A0134E9C886A4and the 90-day risk-premium wrapper0x3Fac8aF279C78Eb82C263EFcacd01D01740Bcfc9. The PoC deploys the current audited localAdmin,SwapCore, andKairosBuyerAdaptercontracts onto the fork. This is necessary because the live BaseSwapCoredeployment artifact exposes an older ABI, while the audited adapter targets the currentbuySwap(bytes32,uint256,address,int256,uint256)and batchedmakePayment(bytes32[])interface. The fixed-rate oracle shim in the test only adapts the old live oracle read surface to the current localITenorRateOracleinterface; every rate read is forwarded to the real Base oracle. No mock contracts are used. There are two fork setup details to make the current audited contracts usable with the selected real vault. First, the selected Gauntlet vault has abdicated adapter-registry changes, so the test uses one scopedvm.mockCallfor the live registry'sisInRegistry(address)read to model the production condition where the Kairos adapter has been accepted by the registry. Second, the live vault auto-allocates ordinary deposits into its liquidity adapter, leaving zero idle USDC. The test seeds an idle Base USDC balance on the real vault to model the realistic state after a keeper or allocator deallocation leaves liquidity available before a manual Kairos allocation. The exploit transaction is executed by an unprivileged shareholder contract. It callsvault.accrueInterest()while the Kairos swap is still open, directly callsSwapCore.makePayment([swapId]), observes that the adapter's realized NAV is lower than the open-swap mark, then redeems the attacker's shares before Vault V2 refreshes the transaction's first total-assets snapshot. The passing Base fork run produced:fork chain id = 8453 real vault = Gauntlet USDC Prime (gtusdcp) real vault address = 0x050cE30b927Da55177A4914EC73480238BAD56f0 real asset = Base USDC swap notional = 200,000,000.000000 USDC assets pushed to adapter = 10,000,000.000000 USDC pre-settlement vault assets = 247,763,986.344113 USDC pre-settlement adapter NAV = 4,890,144.352831 USDC adapter NAV after settlement = 3,317,737.354092 USDC actual post-settlement NAV = 246,191,579.345374 USDC stale NAV gap = 1,572,406.998739 USDC stale redeem amount = 74,657,537.417348 USDC fair post-settlement redeem = 74,183,731.534274 USDC excess extracted = 473,805.883074 USDCThe trace confirms that
SwapCore.makePayment()is called by the attacker contract, emitsSwapClosed, and settles the adapter-owned swap. The followingvault.redeem()burns the attacker's shares and transfers74,657,537.417348 USDC, even though the fair post-settlement value is74,183,731.534274 USDC.Recommendation
Add explicit settlement authorization at the
SwapCoreposition level. When a swap is opened for a managed integration such as a Vault V2 adapter, store a nonzero settlement controller, for example the adapter address.makePayment()should only remain permissionless for swaps that were created with permissionless settlement enabled. For controlled swaps,makePayment()should requiremsg.senderto be the recorded settlement controller or another explicitly authorized settlement executor.KairosBuyerAdaptershould open Vault V2-owned swaps with restricted settlement, then settle those swaps only during allocator-controlledVaultV2.deallocate()accounting. Do not allow permissionlessVaultV2.forceDeallocate()to executeACTION_MAKE_PAYMENTor any other NAV-changing settlement action. Morpho Vault V2 intentionally exposesforceDeallocate()as a permissionless user function, so routing settlement through the adapter is not enough if the adapter still acceptsIVaultV2.forceDeallocate.selectorfor settlement. Do not rely on a generic freshaccrueInterest()call as the mitigation. The issue exists because Vault V2 caches the first total-assets value in the transaction. Once that snapshot is set, later adapter NAV changes must either be impossible before same-transaction share mints or burns or the share action must be blocked/deferred. -
M-01 Medium Fresh LP deposits underprice swap fees Unexpected Behavior Acknowledged
Description
buySwap()prices the utilization fee from the pool's current collateral figures. Fresh LP deposits count immediately in both available liquidity and total collateral. The only waiting period is enforced later, inwithdrawCollateral(). It blocks a withdrawal in the same block as the deposit, but the LP can leave in the next block if the remaining pool still covers the locked collateral. That lets a buyer add temporary LP liquidity, open a swap at a cheaper utilization fee, then withdraw the temporary liquidity one block later. The swap stays open. The remaining LPs keep the exposure, but the fee was calculated as if the temporary liquidity would remain available for the swap term. The PoC compares two 5,000,000 USDC swaps. With only 1,000 USDC of existing LP collateral, the control swap records a 0.3604222096130042% utilization fee, a 5.4604222096130042% swap rate, and 720.980533 USDC of LP backing. When another LP adds 9,000 USDC immediately before the same-size swap, the recorded utilization fee falls to 0.0360490266762789% and the swap rate falls to 5.1360490266762789%, while LP backing remains the same 720.980533 USDC. After one block, the temporary LP withdraws the full 9,000 USDC. Pool total collateral returns to 1,000 USDC, but the underpriced swap remains open with 720.980533 USDC locked against it. In the validatedBUY_FIXEDcase, the buyer is the winner. The control swap requires 787.371623 USDC of buyer collateral plus a 39.368581 USDC liquidation bounty, for 826.740204 USDC paid into the contract. The temporarily priced swap requires only 740.598273 USDC of buyer collateral plus a 37.029913 USDC liquidation bounty, for 777.628186 USDC total. The buyer pays 49.112018 USDC less up front and also locks a lower fixed rate for the same 5,000,000 USDC notional. The losing side is the remaining LP base. Once the temporary LP exits, the legacy LP collateral is still backing 720.980533 USDC of locked exposure, but the swap was priced at the lower utilization fee created by liquidity that is no longer in the pool.Recommendation
Do not let freely withdrawable LP collateral reduce the utilization fee for swaps that can remain open after that collateral leaves. Liquidity that counts toward swap pricing should either stay committed to the exposure it helped price, or it should be excluded from
availableLiquidityandtotalLiquidity. Track pricing-eligible liquidity separately from rawpool.totalCollateralandtotalLPAvailableCollateral(). A withdrawal queue or pro-rata liquidity reservation would all address the issue better than a simple deposit aging period. -
M-02 Medium Morpho adapters accept stale indexes Validation Resolved
Description
CumulativeIndexBorrowMorpho.getCumulativeIndex()tries to project Morpho borrow assets forward when the Morpho market has not accrued in the current block. If projection fails,_tryProject()returns the oldborrowAssetsvalue:try IIrm(params.irm).borrowRateView(params, m) returns (uint256 rate) { uint256 interest = borrowAssets * _wTaylorCompounded(rate * elapsed) / WAD; return borrowAssets + interest; } catch { return borrowAssets; // IRM failed - fall back to stale }The caller then converts that stale asset/share ratio into an index and returns it as valid:
uint256 index = (borrowAssets + VIRTUAL_ASSETS) * 1e27 / (borrowShares + VIRTUAL_SHARES); return (index, index > 0);That is the wrong failure mode for a settlement oracle. If the adapter knows the Morpho market is stale but cannot compute the current projection, it should not mark the old index as safe to use.
RateIndexLib.update(),SwapCore._getFreshIndex()andgetSwapNetAmount()trust theisValidflag. As a result, a Morpho cumulative-index market can keep pricing or settling swaps from an old index exactly when the adapter failed to accrue the index to the current timestamp.CumulativeIndexSupplyMorphohas the same pattern. Its projection helper returns the originalsupplyAssetsandsupplyShareswhenidToMarketParams()orborrowRateView()fails, thengetCumulativeIndex()returns the stale share ratio withisValid = true.Recommendation
Do not report a failed projection as valid. If
block.timestamp > m.lastUpdateand the adapter cannot compute the projection, return(0, false)or revert with a clear oracle error. Settlement-critical callers should reject that data instead of silently using the old Morpho share ratio. If a stale fallback is intentionally allowed, make it explicit and bounded. For example, cap the allowed staleness window and expose whether the returned index is current, projected or stale so callers can choose whether to accept it. -
M-03 Medium Buyer adapter cleanup erases live allocation Logical Error Resolved
Description
KairosBuyerAdapter.deallocate()has a special case for swaps that were already settled directly throughSwapCore. It forwardssettlementPayouts(sid)to the parent vault, removes the settled swap from tracking, and returns only a targeted payout delta. When the payout is larger thanlastReportedAllocation, the adapter setslastReportedAllocationto zero. That loses the value of any other tracked swaps. A profitable externally settled swap can pay out more than the adapter's previous total reported allocation because the old allocation only counted buyer-side collateral and mark-to-market value from the last adapter interaction. If another swap is still tracked,_realAssets()can remain positive after the payout is forwarded, but the adapter has already told the vault that the allocation foradapterIdis zero. Consequently, Morpho cap accounting can understate the vault's remaining Kairos buyer exposure. Allocators can make later decisions against a zero recorded allocation even though the adapter still owns a live swap with recoverable value. This weakens the cap invariant that the adapter's returnedchangekeeps the vault's stored allocation aligned with the adapter's current position value. This is the reintroduced buyer-adapter side of M-02. The M-02 fix made cleanup use_allocationDelta(_realAssets()), but the later cleanup change for L-03 replaced the already-settled accounting with a targeted payout delta to avoid stale-oracle reverts. That fix resolved the L-03 liveness issue while reopening the M-02 allocation invariant for profitable externally settled swaps.Recommendation
Make the already-settled cleanup update allocation from the adapter's remaining value, not only from the settled swap's payout. The safest fix is to recompute
newValue = _realAssets()after forwarding the payout and removing the settled swap, then returnint256(newValue) - int256(oldValue)and storenewValue. If this branch must avoid a full portfolio valuation during oracle outages, store per-swap reported value so cleanup can subtract only the settled swap's previous contribution. Do not let one payout clamp the entire adapter allocation to zero while other tracked swaps remain. -
M-04 Medium Morpho share ratios can reset cumulative indexes Configuration Acknowledged
Description
CumulativeIndexBorrowMorphoandCumulativeIndexSupplyMorphoexpose Morpho Blue share exchange rates as Kairos cumulative indexes. That is not a safe cumulative-index source when the underlying Morpho market can become empty or close to empty. Both adapters calculate the index from live Morpho totals and the Morpho virtual offsets:uint256 index = (borrowAssets + VIRTUAL_ASSETS) * 1e27 / (borrowShares + VIRTUAL_SHARES);and:
uint256 index = (supplyAssets + VIRTUAL_ASSETS) * 1e27 / (supplyShares + VIRTUAL_SHARES);VIRTUAL_ASSETSis1andVIRTUAL_SHARESis1e6. Therefore, when the relevant Morpho side is emptied, the adapter does not return an invalid value. It returns1e21withisValid = true. If the same market previously had a higher share price from accrued interest, a full repayment or full withdrawal can make Kairos observe a sharp decrease in what it treats as a cumulative floating index. This breaks the core assumption behindRateConvention.Cumulative. Kairos stores the adapter value at swap entry and later derives the floating payment from the ratio between the entry index and the settlement index. A stateless Morpho share ratio can move because the market's assets and shares were burned down to zero, not because the realized floating rate became negative. Consequently, a swap can settle from an artificial index drop. For example, the borrow adapter can report about1.05e27while the Morpho borrow side has1,050,000e18borrow assets and1,000,000e18borrow shares. After all borrow shares are repaid, the same adapter reports1e21and still marks the value valid. Kairos' index-ratio math then interprets that reset as a very large negative floating rate over the swap term. The supply adapter has the same structural problem when the supply side can be withdrawn to zero. The near-empty case is also unsafe. When borrow or supply shares are small relative to the1e6virtual shares, the virtual offsets dominate the ratio. Small changes in assets, shares or rounding can then create large percentage moves in the reported index. This makes new, quiet or attacker-dominated Morpho markets unsuitable as settlement indexes for Kairos markets. Due to this issue, Morpho share exchange rate is not a persistent cumulative index unless the market is guaranteed to remain deep, organic and non-empty for the whole Kairos swap lifecycle.Recommendation
Do not use raw Morpho share exchange rates as stateless cumulative index oracles unless the protocol enforces strong non-empty and minimum-liquidity assumptions. At minimum, both adapters should reject empty and dust states. For example, return
(0, false)whentotalBorrowSharesortotalSupplySharesis below a configured minimum for the corresponding adapter. The minimum should be large enough that Morpho's virtual offsets cannot materially dominate the exchange rate. A safer design is to maintain a stateful monotonic index wrapper for Morpho markets. The wrapper should store the last accepted index and reject decreases except for a tightly bounded rounding tolerance. If the protocol cannot enforce monotonicity on top of the Morpho share ratio, these adapters should not be eligible asRateConvention.Cumulativesources for settlement-critical Kairos markets. -
M-05 Medium Buyer entry fees can be socialized Unexpected Behavior Acknowledged
Description
KairosBuyerAdapter.allocate()lets a Morpho Vault V2 allocator use vault funds to buy a Kairos buyer swap. By the time the adapter runs, the vault has already transferredassetsto it. The adapter then callsswapCore.buySwap(...), which pulls buyer collateral, the prefunded liquidation bounty and the protocol fee from the adapter. The collateral and bounty still belong economically to the vault position. They are counted again when the adapter reports_realAssets(). The protocol fee is different. It is transferred out duringSwapCore.buySwap(), so the vault loses that value as soon as the swap is opened. That immediate loss can be pushed onto users who stay in the vault. Morpho Vault V2 accrues before it calls the adapter. If an allocator also holds vault shares, or coordinates with a shareholder, the same transaction can buy a fee-bearing swap and then redeem shares while the vault is still using the pre-fee total-assets snapshot. The redeemer exits before their share of the entry fee is reflected in the share price. The remaining depositors absorb the shortfall. This is a bounded issue, not a full-vault drain. The fee is capped byAdmin.MAX_PROTOCOL_FEE_RATE, but it can still be meaningful for large or long-dated swaps because it is computed from notional and term:protocolFee = notionalAmount * feeRate * swapTerm / YEAR / WAD;At the maximum 3 percent annual fee, a 30-day swap pays about 0.246 percent of notional, while a one-year swap pays 3 percent. The value shifted to remaining depositors is roughly the fee paid multiplied by the redeemer's share of the vault. The required conditions are allocator control or allocator coordination, same-transaction batching and enough vault liquidity for the redeem.
Recommendation
Treat fee-bearing buyer allocations as NAV-changing actions that cannot be mixed with same-transaction share exits priced from an older total-assets snapshot. Consider making vault-owned buyer swaps fee-free or keep the fee small enough that the loss is explicitly negligible for Morpho Vault V2 integrations. If vault-owned buyer swaps must pay material fees, block share redemptions in the same transaction as those allocations or enforce an allocator policy that prevents fee-paying allocations from being batched with exits by the allocator or a cooperating shareholder. Curators should not enable this adapter for fee-bearing buyer swaps unless that policy is in place.
-
M-06 Medium Poke spam blocks TWAR ring advancement Unexpected Behavior Resolved
Description
IndexBaseRateV1.poke()decides whether to advance the ring buffer by comparing the current timestamp toobservations[head].timestamp + minSpacingSec. If the call arrives too early, the function does not advance the ring. It still overwrites the head observation with the current timestamp. Consequently, a caller can repeatedly callpoke()just beforeminSpacingSecelapses. Each non-advancing call refreshesobservations[head].timestampand pushes the next advancement threshold forward. With the reviewed deployment configuration oftwarWindowSec == minSpacingSec == 12 hours, this can keep the ring from adding new historical anchors while the oracle continues to accept permissionless pokes. Reads then fall back to older observations when_realizedTwar()searches for the youngest anchor at or beforeblock.timestamp - twarWindowSec. The returned rate can be based on a long interpolation chord rather than the intended trailing TWAR window. Since this oracle can be used as a base-rate source, degraded quotes can affect swap entry pricing, collateral requirements, early exits and LP mark-to-market.Recommendation
Do not let non-advancing pokes reset the ring-advancement clock. The simplest fix is to ignore pokes before
minSpacingSechas elapsed or update only the head index while preserving the original head timestamp. If replacement pokes are still needed, track a separatelastAdvancedTimestampand base ring advancement on that value instead of the replaceable head observation timestamp. Limit in-place replacement to a narrow deduplication window, such as same-block updates, so permissionless callers cannot indefinitely delay new ring slots. -
M-07 Medium Permissionless updates starve rate snapshots Logical Error Resolved
Description
RateIndexLib._appendSnapshot()overwrites the latest snapshot whenever a new update is less thanMIN_SNAPSHOT_GAPseconds after the previous snapshot. That is unsafe becauseSwapCore.updateMarketRateIndex()is permissionless. A caller can keep calling it every 11 seconds and repeatedly move the last snapshot forward instead of letting the snapshot array grow. This can erase the only historical anchor for an oracle. The dangerous case is when the overwrite pattern continues after expiry. Once the earliest stored snapshot has been moved past the target expiry,getIndexAt()treats the target as being before the first snapshot and returns that later snapshot value. Example sequence:- A market is initialized at
12:00:00, and the first snapshot is stored. - A caller updates the market every 11 seconds. Each update overwrites the same snapshot slot instead of appending a new one.
- A swap expires at
12:00:30. - The caller keeps updating after expiry, so the only retained snapshot continues moving forward.
- By
12:10:00, the only retained snapshot is post-expiry. - Settlement calls
getIndexAt(12:00:30). Since12:00:30is earlier than the first retained snapshot, the function returns the12:10:00index. That is the post-expiry contamination this issue flags. The bug is not that every sparse snapshot set uses future data. The bug is that permissionless updates can keep moving the retained snapshot until missing history is silently replaced with post-expiry data.
Recommendation
Do not use a time-window overwrite rule for economically meaningful snapshots. If deduplication is needed, only replace snapshots written in the same block:
if (len > 0 && snaps[len - 1].timestamp == uint48(block.timestamp)) { snaps[len - 1] = Snapshot(uint48(ts), uint208(val)); } else { snaps.push(Snapshot(uint48(ts), uint208(val))); }Also make historical lookup fail closed when the target timestamp is earlier than the earliest retained snapshot. Returning the first retained snapshot for an older target silently turns missing history into future pricing data.
- A market is initialized at
-
L-01 Low Buyer adapter counts liquidator bounty in NAV Logical Error Resolved
Description
KairosBuyerAdapter._realAssets()values each unsettled buyer swap ascollateralBalance + liquidationBounty, adjusted by the current net PnL fromSwapCore.getSwapNetAmount(). That is not conservative once the buyer side is liquidatable. At that point the bounty is no longer recoverable by the vault, it is reserved for the liquidator. SincegetSwapNetAmount()caps the buyer's loss atcollateralBalance, the adapter can still report the prefunded bounty as NAV even thoughliquidateSwap()will pay that same amount away. This overstates the Morpho vault's adapter value and allocation. If the vault has other liquid assets, users can redeem against a share price that includes value the strategy will not recover. The loss is then left with the remaining depositors when liquidation clears the swap. The impact is bounded to the bounty on each affected swap, but it can scale with the number and size of buyer-adapter positions.Recommendation
Value buyer swaps using the same recoverable amount that
SwapCorewould pay if the position were closed. Consider exposing a conservativeSwapCoreview for buyer recovery and have_realAssets()use it. That view should apply the same caps and liquidation rules as settlement and liquidation. At minimum,_realAssets()should check whether the buyer side is liquidatable before addingliquidationBounty. If liquidation can take the bounty, exclude it from NAV. -
L-02 Low Expired settlement still needs live oracle DoS Resolved
Description
_settleSwaps()updates the live reference-rate oracle before it checks whether an expired swap already has a usable maturity index.uint256 oracleIndex = rateIndex.update(market.referenceRateOracle); uint256 expiryIndex = 0; if (!swap.isEarlyExit) { uint256 actualExpiry = swap.entryTimestamp + market.swapTerm; if (block.timestamp >= actualExpiry) { expiryIndex = rateIndex.getIndexAt(market.referenceRateOracle, actualExpiry); if (expiryIndex == 0) expiryIndex = oracleIndex; } }That order is wrong for ordinary expiry settlement. Once the maturity index has been recorded, settlement should be able to use the historical value without depending on the current oracle. Today, if the oracle becomes stale or starts reverting after maturity,
rateIndex.update()reverts beforegetIndexAt()is reached. The swap is already expired and can be priced from the stored snapshot, but it still cannot settle. This also blocks LP mobility. An expired unsettled swap at the queue head keepswithdrawCollateral()and related LP flows blocked. A current oracle outage can therefore freeze withdrawals and vault cleanup even though the expired swap's value is already known.Recommendation
For normal expiry settlement, read the maturity index first. If
getIndexAt(referenceRateOracle, actualExpiry)returns a valid value, settle with that value and skip the liverateIndex.update(). Only use a fresh oracle update when the historical index is missing and the protocol explicitly wants live-oracle fallback pricing. If no fallback should be allowed, revert with a clear missing-expiry-index error instead. Early exits and liquidations can still require a fresh oracle because they price current state. Expiry settlement should not require it once the maturity snapshot exists. -
L-03 Low SpotCompound floor blocks settlement DoS Acknowledged
Description
SwapFormulas.deriveRateFromIndex()computes theSpotCompoundRatelog input from a WAD-scaled index ratio:int256 indexRatio = safeCast(Math.mulDiv(closingFloatingIndex, WAD, entryFloatingIndex)); if (convention == Types.RateConvention.SpotCompoundRate) { int256 lnRatio = FixedPointMathLib.lnWad(indexRatio); return (lnRatio * int256(SECONDS_IN_YEAR)) / int256(timeElapsed); }That ratio can round to zero even when
closingFloatingIndexis nonzero. A normal swap enters aroundINITIAL_INDEX(1e27). If the closing snapshot is floored toMIN_INDEX(1e6), the ratio becomes:1e6 * 1e18 / 1e27 == 0The next line calls
lnWad(0), which reverts. As a result, aSpotCompoundRateswap with a deeply floored expiry index cannot be quoted or settled. If that swap is expired and still unsettled, it can also keep LP withdrawals blocked through the expired-unsettled guard.Recommendation
Do not let a nonzero closing index produce a zero log input. Compute the compound index ratio at higher precision before calling
lnWad(), or derive the compound settlement rate directly from cumulative rate-time snapshots instead of a rounded WAD-scaled index ratio. IfMIN_INDEXremains part of the index model, set it high enough that every supported entry index produces a nonzero ratio. Settlement code should also reject floored historical indexes with a clear protocol error instead of reachinglnWad(0). -
L-04 Low Same-block cumulative index is stale Logical Error Resolved
Description
RateIndexLib.update()returns the cached index when the oracle was already updated in the same block:uint256 elapsed = block.timestamp - index.lastUpdates[oracle]; if (elapsed == 0) return index.indices[oracle]; uint256 newIndex; Types.RateConvention convention = index.conventions[oracle]; if (convention == Types.RateConvention.Cumulative) { (uint256 ci, bool isValid) = ICumulativeRateOracle(oracle).getCumulativeIndex(); if (!isValid || ci == 0) revert Errors.E606(); newIndex = ci; }That early return happens before the code checks whether the oracle uses
RateConvention.Cumulative. For spot-rate modes this is reasonable because no time has elapsed. For cumulative oracles, the source index can still change inside the same block if the adapter reads live state from another protocol.buySwap()stores the value returned byrateIndex.update()asentryFloatingIndex. If the cumulative oracle has already moved in the same block, a new swap can record the old cached index as its entry value. Later quotes then treat the pre-entry index move as if it happened after the swap opened.Recommendation
Do not return the cached value for cumulative oracles only because
elapsed == 0. Move the convention check before the same-block early return. ForRateConvention.Cumulative, readICumulativeRateOracle.getCumulativeIndex()even in the same block, validateisValidand update the cached index when the external index has changed. If same-block snapshot deduplication is still desired,_appendSnapshot()can keep overwriting the current timestamp snapshot. The important rule is that state-changing calls such asbuySwap()must not store an older cumulative index when the oracle already reports a newer one. -
L-05 Low Compound adapter accepts stale index Unexpected Behavior Resolved
Description
CumulativeIndexBorrowCompound.getCumulativeIndex()reads Compound's storedbaseBorrowIndexand projects it forward whenlastAccrualTimeis older than the current timestamp. Projection is best-effort. If either Compound read fails,_tryProject()returns the old index:function _tryProject(uint256 borrowIndex, uint256 elapsed) private view returns (uint256) { try comet.getUtilization() returns (uint256 utilization) { try comet.getBorrowRate(utilization) returns (uint256 borrowRate) { return borrowIndex + borrowIndex * borrowRate * elapsed / FACTOR_SCALE; } catch { return borrowIndex; } } catch { return borrowIndex; } }The caller then scales that stale value and returns it as valid:
return (borrowIndex * SCALE_FACTOR, true);That is unsafe for a cumulative settlement oracle. If the adapter knows the Compound market is stale but cannot compute the current projection, it should not tell
SwapCorethe old index is valid.SwapCoretrusts theisValidflag for cumulative oracles. Quotes, liquidation checks, LP mark-to-market and settlement can therefore use an old Compound borrow index exactly when the adapter failed to compute current accrual. This is separate from the zero-debt Compound issue. HeretotalBorrowBaseis nonzero and projection would be valid if the Compound calls succeeded. The bug is that a failed projection is silently downgraded to a stale valid index.Recommendation
Do not report a failed projection as valid. If
elapsed > 0and eithergetUtilization()orgetBorrowRate()fails, return(0, false)or revert with a clear oracle error. Settlement-critical callers should reject that read instead of pricing from the old stored borrow index. If a stale fallback is intentionally allowed, make it explicit and bounded. For example, cap the maximum accepted staleness and expose whether the returned value is current, projected or stale. -
L-06 Low Idle LP withdrawals need live oracle Unexpected Behavior Resolved
Description
withdrawCollateral()always callsrateIndex.update(market.referenceRateOracle)and thengetPoolSharePrice()before it decides how many shares to burn. This happens even whenpool.lockedCollateral == 0, so no active swap needs mark-to-market pricing and a full withdrawal can be computed from idle pool collateral. If the reference-rate oracle reverts or is invalid after LP collateral has been supplied, an LP cannot withdraw from an idle market. The funds remain inSwapCoreuntil the oracle recovers, even though the market has no open exposure and no settlement price is needed to return the collateral.supplyCollateral()has the same unnecessary update before its zero-share branch, but the locked-fund case is the LP withdrawal.Recommendation
Skip reference-oracle updates when no active swaps are locked. For idle pools, redeem shares directly against
pool.totalCollateral / pool.totalSharesor use cached accounting that does not call the reference oracle. Keep the fresh oracle update only whenpool.lockedCollateral > 0and mark-to-market pricing can affect share value. -
L-07 Low Market creation skips base batch API check Validation Resolved
Description
_initializeMarket()validates the base swap rate oracle by calling onlygetRateByTenor(params.swapTerm). Later pricing code uses the batch interface throughUtils.getBaseRateOracleBatch(), which callsITenorRateOracle.getRates(tenors)and reverts if that call fails or returns invalid. A base oracle can therefore pass market creation while still being incompatible with the functions used by normal market accounting. This can happen with a legacy or custom oracle that implementsgetRateByTenor()correctly but does not implementgetRates()or returns invalid batch results. Once such a market exists, operations that need base rates for multiple tenors can revert. That can block LP actions, buyer actions, or settlement-adjacent accounting until the market is migrated or the oracle is replaced through an available administrative process.Recommendation
Validate the same oracle interface that production accounting uses. During market creation, call
getRates()with the exact tenor set that downstream pricing requires and reject the oracle unless it returns the expected number of rates withisValid = true. If single-tenor oracles should remain supported, add an adapter that implements the fullITenorRateOraclebatch interface by safely looping over the underlying single-tenor calls. -
L-08 Low New bounds leave stored rates valid Validation Resolved
Description
BaseTimelockedOraclevalidates bounds only when new rates are posted._validateCoreRateConstraints()rejects a submitted rate outsideminRateBoundWadandmaxRateBoundWad, but_getRatesInternal()later returns already-stored rates without checking them against the currently active bounds. When the owner queues and activates tighter rate bounds, fresh stored rates that now violate the new bounds can continue to be served as valid until they become stale or until a new post is accepted. This breaks the expected safety property of the bound change. The system can keep using a rate that governance has already declared out of bounds. The issue affects all child oracles that inherit this read logic, including fixed-rate pricing and risk-rate oracles. Those values feed market creation, pricing, and mark-to-market calculations through the tenor-rate oracle interface.Recommendation
Recheck active bounds in
_getRatesInternal()before returningtrue. If any stored or interpolated rate is outside the currently active bounds, return invalid or revert with a clear error. When activating a tighter bound, also consider invalidating stored buckets that are outside the new range. This makes the change take effect immediately instead of waiting for staleness. -
L-09 Low Buyer adapter drops early-exit slippage Validation Resolved
Description
SwapCore.exitSwapEarly()supports a per-swapminExitAmount. The buyer adapter does not expose that protection. For every adapter-driven early exit, it builds a singleExitRequestwithminExitAmountset to zero. Consequently, a vault allocator cannot tell the adapter to reject an early exit that returns too little value. The core contract can enforce the bound, but the adapter always disables it before callingswapCore.exitSwapEarly(). This leaves vault execution exposed to unfavorable oracle movement, rate movement or stale off-chain quotes between the allocator's decision and the transaction that reaches the chain.Recommendation
Include
minExitAmountin the adapter action data forACTION_EARLY_EXITand pass it through toSwapCore.exitSwapEarly(). If existing deployments need backward compatibility, support the current two-word payload as the unsafe legacy form and add a longer payload that carries the slippage bound. Vault allocators should be able to make early exit conditional on a minimum realized payout, the same way directSwapCoreusers can. -
L-10 Low Market adapter leaves withdrawal surplus Unexpected Behavior Resolved
Description
KairosMarketAdapter.deallocate()assumes a partial withdrawal fromSwapCore.withdrawCollateral()returns exactly theassetsamount that Morpho Vault V2 will pull from the adapter. That is not always true for the last LP.SwapCore.withdrawCollateral()can round a partial withdrawal up to all remaining shares. If the pool has no locked collateral, it then sweepspool.totalCollateralto the receiver rather than returning the requestedassetsamount. The adapter keeps partial withdrawals in pull mode, so the vault only pullsassets. Any surplus stays as a raw token balance on the adapter. That surplus is not reported byKairosMarketAdapter._realAssets(), because_realAssets()only values remaining LP shares. Consequently, the adapter can hold vault-owned tokens while reporting zero assets. The funds are not immediately stolen, and an allocator can later sweep them, but the vault's adapter accounting is understated and the funds can remain stranded operationally.Recommendation
Make partial withdrawals enforce the adapter's exact-amount assumption:
if (!fullAmount && withdrawn != assets) revert UnexpectedWithdrawalAmount(withdrawn, assets);Alternatively, handle over-withdrawals explicitly by pushing
withdrawn - assetsto the parent vault and include any residual token balance in_realAssets()until it has been swept. -
L-11 Low Stale signed oracle updates appear fresh Validation Resolved
Description
The signed tenor-oracle update paths do not bind signatures to the time when the off-chain data was observed.
FixedRatePricingOracle.postForecasts()signsnonce,deadline,tenorsHash,baseRatesHash,ciLowersHash,ciUppersHash, andstdDevsHash.RiskRateOracle.postRiskPremiums()signsnonce,deadline,tenorsHash,upRatesHashandloRatesHash. Neither payload includes an observation timestamp or an equivalent signed validity window. When either update is eventually accepted, the child oracle stores the data timestamp as the relay block timestamp:FixedRatePricingOraclewrites each forecast withupdatedAt = block.timestampandRiskRateOraclewrites each risk-premium bucket withupdatedAt = block.timestamp.BaseTimelockedOraclethen treats that relay timestamp as the data freshness timestamp. A relayer holding a valid signed update can withhold it until a later block, submit it beforedeadlineand make stale off-chain data appear fresh for a fullmaxAgeSecwindow from relay time. This also interacts with the rate-change circuit breaker:_validateRateVector()only checksmaxRateChangeWadwhen the previous stored bucket for that rate vector is still fresh. Once the previous bucket has aged pastmaxAgeSec, the same delayed signed payload can bypass a change that would have reverted if relayed immediately. The affected downstream sinks differ by oracle:FixedRatePricingOraclefeeds base-swap tenor rates. Markets using it asbaseSwapRateOraclecan consume stale signed forecasts for market creation, swap entry, collateral requirements, and mark-to-market calculations.RiskRateOraclefeeds sided risk-premium wrappers.UpgradableSidedPremiumOracle.getRate()exposes eithergetUpRateByTenor()orgetLoRateByTenor(),Utils.getRiskPremiumOracleRate()only checksisValid = trueand a non-negative rate, andSwapCore.buySwap()adds the returned premium to BUY_FIXED pricing or records it for BUY_FLOATING fee accounting. A delayed relayer cannot invent rates or deadlines, but if signer deadlines exceed the intended freshness window, markets can price new swaps against stale signed forecasts or risk premiums that appear fresh on-chain. Example:
- An oracle has a fresh 5% bucket value.
- Authorized signer(s) sign a 20% update with a deadline two hours in the future.
- If the update is posted immediately, it reverts because the 15% jump exceeds
maxRateChangeWad. - A relayer waits until the previous 5% bucket is older than
maxAgeSec, but still before the signed deadline. - The relayer posts the same signed update. The change limit is skipped because the previous value is stale and the new value is stored with
updatedAt = block.timestamp. - Downstream reads return the delayed 20% value with
isValid = true, even though the signed data was produced earlier.
Recommendation
Include a signed observation timestamp, or a signed validity window such as
validAfter/validUntil, in bothFORECAST_UPDATE_TYPEHASHandRISK_PREMIUM_UPDATE_TYPEHASH. Reject updates whose observation timestamp is older than the configured freshness window at submission time. Base freshness checks should use the signed observation timestamp or a separate stored observation timestamp, rather than the relay timestamp. If the relay timestamp is still useful for operations, store it separately from the data timestamp. Also consider enforcingdeadline <= observedAt + maxAgeSecso signer deadlines cannot exceed the oracle freshness policy. -
L-12 Low Inherited renounce bypasses wrapper timelock Unexpected Behavior Resolved
Description
BaseUpgradableOracleWrapperstill inherits OpenZeppelinOwnable2Step, which in turn inheritsOwnable.renounceOwnership(). The wrapper does not override that inherited external function. As a result, the owner can immediately setowner()to the zero address without usingqueueRenounceUpgradability()andactivateRenounceUpgradability(). This bypasses the wrapper's current three-day upgradability renouncement process. It also leavesupgradabilityRenouncedasfalse, emits noUpgradabilityRenouncedevent and does not clear an in-flight downstream change. SincequeueDownstream(),activateDownstream(),cancelPendingDownstream()and the wrapper renouncement functions are allonlyOwner, the pending downstream cannot be activated or cancelled after ownership is renounced.Recommendation
Override
renounceOwnership()inBaseUpgradableOracleWrapperand make it revert with a wrapper-specific error or route ownership renouncement through the existing timelockedqueueRenounceUpgradability()andactivateRenounceUpgradability()process. If the project wants ownership to remain transferable after upgradability is renounced, document that separately from the wrapper's own upgradability state. -
L-13 Low Long min poke interval forces stale rates Validation Acknowledged
Description
The constructor only rejects
_minPokeIntervalSecvalues below_twarWindowSec. This matches the bracketing requirement immediately after a successfulpoke(), but it is not enough for continuous liveness. If_minPokeIntervalSecis greater than_twarWindowSec, the target timeblock.timestamp - twarWindowSecmoves pastcurr.timestampafter one window. At that pointgetRateByTenor()andgetRates()return invalid. The oracle still cannot be refreshed becausepoke()reverts withTooSoon()until the longer minimum interval elapses. Consequently, a deployment can have a forced invalid interval on every keeper cycle even when a keeper is online and willing to refresh the oracle.SwapCoretreats an invalid base-rate oracle as a hard failure throughUtils.getBaseRateOracle()andUtils.getBaseRateOracleBatch(), so affected markets can reject buys, early exits and LP mark-to-market operations during those intervals. The provided Base deployment script sets both values to 12 hours, so this issue is not active for that deployment. The constructor and documentation still permit the unsafe configuration.Recommendation
For this two-checkpoint design, require
_minPokeIntervalSec == _twarWindowSecor remove the configurable minimum and usetwarWindowSecas the rotation gate. If a separate anti-spam delay is needed, add a distinct maximum liveness requirement and test that no accepted configuration creates a period where reads are invalid whilepoke()is still too soon. -
L-14 Low Tenor wrapper accepts short rate batches Validation Resolved
Description
UpgradableTenorRateOracle._validateDownstreamHealth()checks thatgetRateByTenor()succeeds and thatgetRates()returnsisValid = truefor a one-element probe. It does not check that the returned batch has the same length as the requested tenor array. The wrapper then forwardsgetRates()unchanged.Utils.getBaseRateOracleBatch()also returns the downstream array after only checkingisValid. Downstream share-price code assumes the result length matches the active bucket count andUtils.calculateTotalUnrealizedPnL()indexesrates[i]for every active bucket after the batch call. If the downstream returns a short array withisValid = true, the market can pass wrapper validation but later revert during LP share-price calculation.Recommendation
Validate batch length in the wrapper and in the shared utility.
UpgradableTenorRateOracle._validateDownstreamHealth()should requirerates.length == probe.lengthfor its health probe andgetRates()should reject any downstream response whose length differs from the requested tenor array.Utils.getBaseRateOracleBatch()should also enforcerates.length == tenors.lengthbefore callers index into the array. That gives the core accounting code a defensive check even if a non-wrapper oracle is configured directly. -
L-15 Low Late recovery pokes smear Aave index growth Logical Error Pending
Description
_realizedTwar()treats any target timestamp bracketed byprevandcurras valid, then deriveshistoricalIdxby linear interpolation across the full checkpoint span. After a keeper outage,poke()promotes the oldcurrcheckpoint toprevand stores the live index incurr. The new span can be many times larger thantwarWindowSec, but reads immediately returnisValid = true. That makes the first recovery window depend on an assumed linear index path across the outage rather than the actual trailing window. If the Aave index moved mostly in the last TWAR window, the oracle smears that move over the full outage and materially underquotes the base rate. If the move happened earlier, it can overquote.SwapCoreconsumes the result for buys, early exits and LP mark-to-market, so users can trade against a distorted base rate immediately after recovery. A missed keeper period can be followed by a valid quote before the oracle has two fresh checkpoints around the trailing window.Recommendation
Do not return a valid rate when
curr.timestamp - prev.timestampis materially larger thantwarWindowSec. For this two-checkpoint design, return(0, false)after a late recovery poke until a second on-time poke creates a fresh checkpoint pair. If immediate recovery is required, store a larger checkpoint ring buffer and interpolate from the nearest snapshots aroundblock.timestamp - twarWindowSec. -
L-16 Low Long update interval forces invalid windows Validation Resolved
Description
queueMinUpdateIntervalChange()accepts any nonzero_minUpdateIntervalSec, andactivateMinUpdateIntervalChange()stores it without checking it againstmaxAgeSec. The constructor has the same nonzero-only check for the initial value. That allowsminUpdateIntervalSecto be configured abovemaxAgeSec. Once that happens, every successful post creates a forced invalid window._getRatesInternal()returns invalid after the oldest active bucket is older thanmaxAgeSec, but_validateTenorsAndCadence()still rejects a new signed post withUpdateTooFrequentuntilminUpdateIntervalSechas elapsed fromlastPostTimestamp. Consequently,getRateByTenor()andgetRates()can return invalid while an otherwise valid signer update cannot restore liveness. Consumers that require a valid tenor oracle then revert even though signers and relayers are available.Recommendation
Reject any
minUpdateIntervalSecgreater thanmaxAgeSecin the constructor and inqueueMinUpdateIntervalChange(). Also rejectqueueMaxAgeChange()values below the activeminUpdateIntervalSecor require both values to be updated through a single coordinated timelocked change. -
L-17 Low Wrappers reject non-ERC165 oracle adapters Compatibility Acknowledged
Description
BaseUpgradableOracleWrapperdoes not accept a downstream oracle just because the oracle has the right read functions. Before construction, queueing or activation succeeds, the wrapper callssupportsInterface()on the downstream and requires it to advertise bothIOracleMetadataand the wrapper's required read interface. That rule is stricter than several repository adapters currently satisfy. For example,ChainlinkAdapter,PythAdapterandTenorRiskPremiumWrapperexposeIRateOracle-style functions, but they do not implement ERC-165. If one of those adapters is used as the downstream forUpgradableRateOracle, the wrapper's interface check fails. A missingsupportsInterface()function causesDownstreamUnhealthy; a false return would causeInterfaceNotSupported.Recommendation
Choose one wrapper compatibility rule and enforce it consistently. If repository adapters are allowed to sit behind
BaseUpgradableOracleWrapper, make each adapter inheritERC165and implementsupportsInterface()forIOracleMetadataplus its read interface, such asIRateOracle. If some adapters are intentionally direct-use only, document that restriction and add deployment tests that fail when those adapters are wired throughUpgradableRateOracleor anotherBaseUpgradableOracleWrapperchild. -
L-18 Low Pyth adapter reverts on invalid prices Unexpected Behavior Resolved
Description
PythAdapter.getRate()returns(rate, isValid), but several invalid oracle states revert instead of returning(0, false).getPriceNoOlderThan()can revert for missing or stale prices and the adapter also reverts for non-positive prices, future timestamps, stale timestamps and scaling failures. Callers that are written against theIRateOraclevalidity flag cannot handle these cases cleanly. A stale or malformed Pyth price can therefore bubble up as a hard revert in pricing, settlement or maintenance logic that expected an invalid-rate response.Recommendation
Wrap the Pyth read in
try/catchand return(0, false)for stale, missing, malformed, future-dated or unscalable prices. -
L-19 Low Short min spacing can erase TWAR history Validation Resolved
Description
IndexBaseRateV1only requires_minSpacingSecto be nonzero. The ring buffer has eight slots, so a deployment withminSpacingSecbelowtwarWindowSec / (N - 1)cannot retain enough history whenpoke()is called as often as the contract allows. This matters becausepoke()is permissionless. For example, with a 12 hour TWAR window and 1 hour spacing, eight hourly pokes overwrite both constructor observations and every slot older thannow - twarWindowSec. After that,_findLeftAnchor()cannot find a left anchor andgetRateByTenor()returns(0, false)even though the cumulative source is healthy.SwapCoretreats an invalid base-rate oracle as a hard failure throughUtils.getBaseRateOracle()andUtils.getBaseRateOracleBatch(). The supplied deployment task usesminSpacingSec == twarWindowSec, so this is not active for that configuration. The constructor still permits an unsafe configuration and the permissionless update function makes the liveness break externally triggerable once such a deployment exists.Recommendation
Reject configurations that cannot retain at least one observation at or before the TWAR target under minimum-spacing pokes. A direct guard is:
if (_minSpacingSec * (N - 1) < _twarWindowSec) revert InvalidSpacing();Keep the existing deployment default of
minSpacingSec == twarWindowSec. -
L-20 Low Tenor bucket activation blocks reseeding Validation Resolved
Description
activateTenorBucketsChange()replaces the activetenorBucketsarray but leaveslastPostTimestampunchanged. If the new tenor set contains a bucket that has never been posted before, that bucket's stored timestamp is zero.getRateByTenor()andgetRates()then return invalid because_getRatesInternalWith()requires every active bucket to have a nonzero fresh timestamp. This fail-closed behavior is reasonable on its own. The liveness problem is that the next signed post is still gated by the oldlastPostTimestamp. If signers posted the old bucket set shortly before the owner activates the pending bucket change,_validateTenorsAndCadence()rejects the first post for the new bucket set withUpdateTooFrequentuntilminUpdateIntervalSechas elapsed. Consequently, an owner can complete the timelocked bucket change and create a deterministic oracle outage even when fresh signatures for the new bucket set are available. Consumers that require a valid tenor oracle, such as market creation or swap pricing through wrappers, remain unavailable for the remaining cooldown. With the public deployment defaults observed in the scripts, this is bounded by the four-hour update interval, so the issue is a liveness/configuration hazard rather than a direct loss of funds.Recommendation
Make tenor-bucket activation start a fresh posting window for the new bucket set. The smallest fix is to reset
lastPostTimestampwhen the bucket set is activated:tenorBuckets = pending.newTenorBuckets; lastPostTimestamp = 0;Alternatively, allow one immediate post after a bucket-set activation, require the pending bucket change to include initial signed rates for every new bucket, or make the activation function reject unless
minUpdateIntervalSechas already elapsed since the last successful post. -
L-21 Low MIN_SHARE_PRICE floor overpays withdrawing LPs Unexpected Behavior Acknowledged
Description
Utils._computeSharePrice()does not return the raw pool MtM share price when pool value falls belowMIN_SHARE_PRICE. It returns1e12even if the real value per share is lower or zero. That value is not only a display fallback.SwapCore.withdrawCollateral()uses it to compute an LP'sfullValueand the number of shares burned for an exact-amount withdrawal. Therefore, once a pool is deeply underwater, the first LPs to withdraw burn shares at the artificial floor instead of the true lower price. For example, if a pool has 1 USDC left and 2,000,000e6 shares outstanding, the raw share price is5e11, below the floor.getPoolSharePrice()reports1e12. An LP can withdraw the remaining collateral in two 0.5 USDC withdrawals while another LP still holds 1,000,000e6 shares. The remaining LP is left with shares against an empty pool. The same inflated value is also consumed bygetLpValue(),getPoolSharePriceVirtual()andKairosMarketAdapter._realAssets(), so vault accounting can report floor value after the pool's real economic value has fallen below it.Recommendation
Separate the raw economic redemption price from any display or safety floor.
withdrawCollateral(),getLpValue(), virtual share price and adapter NAV should use the raw MtM share price or revert when the raw value is below the floor. If active swaps remain and raw pool value is belowMIN_SHARE_PRICE, block LP deposits and withdrawals or require proportional liquidation-style exits. The floor can remain as an explicitly labelled UI fallback, but it should not determine how many shares are burned or how many assets are reported. -
L-22 Low IndexBaseRateV1 ignores post-target anchors Unexpected Behavior Resolved
Description
IndexBaseRateV1._realizedTwar()looks for the youngest stored observation at or beforeblock.timestamp - twarWindowSec. It then interpolates from that left observation directly to the live source index. This ignores any stored observation after the target timestamp. If the source index did not grow linearly between the left observation and the live read, post-target rate movement can influence the estimated historical index at the start of the TWAR window. The reported trailing rate can therefore be biased when observations are sparse or when rate movement is concentrated near the end of the interpolation span. For example, assume the TWAR window is 12 hours and the read happens at 12:00, so the target is 00:00. The ring may contain observations at 18:00 yesterday, 06:00 today, and the live index at 12:00. A bracketing lookup would use the 18:00 and 06:00 observations to estimate the index at 00:00. The current code instead uses 18:00 and the live 12:00 index. If most index growth happened after 06:00, that later growth is smeared backward into the estimate for 00:00 and can understate the trailing 00:00 to 12:00 rate. This is especially visible when ring advancement is delayed or when the oracle has a long gap between useful anchors. In that case_realizedTwar()can still returnisValid = true, but the quote is based on a long chord ending at the live index rather than on observations that bracket the intended TWAR boundary.Recommendation
Use a true bracketing lookup around
block.timestamp - twarWindowSec. The oracle should find both the closest observation at or before the target and the closest observation at or after the target, then interpolate between those two observations. If no right anchor exists, return invalid unless the live read is within a small configured maximum gap from the target. This keeps valid quotes tied to the intended trailing window instead of accepting a stale-to-live interpolation over an unbounded span. -
L-23 Low Market Can Use Wrong Risk Tenor Validation Acknowledged
Description
SwapCore.createMarket() accepts a risk premium oracle as a generic IRateOracle. It validates only that getRate() returns valid, and buySwap() later reads the same no-argument getRate() value when pricing each swap. That loses the market tenor at the risk-premium boundary. Tenor-specific premium wrappers can expose getRate() by reading a fixed configured tenor, such as defaultTenorSeconds = 30 days. If that wrapper is assigned to a 90-day or 180-day market, SwapCore has no way to detect the mismatch. Market creation passes, and every swap in that market is priced with the wrapper’s default-tenor premium rather than the market’s swapTerm premium. Because market creation is permissionless, any creator can deploy or choose a valid risk-premium wrapper with the wrong default tenor, create a longer-dated market, and have it pass protocol validation. If LPs later supply that market, buyers can trade long-dated risk using a shorter-tenor premium. Example: the risk oracle reports a 1% premium for 30 days and an 8% premium for 180 days. A 180-day market is created with a wrapper whose getRate() reads the 30-day bucket. Creation succeeds because getRate() is valid. Buyers then open 180-day swaps while paying the 1% short-tenor premium instead of the intended 8% long-tenor premium. The loss direction depends on the mismatch, but in the underpriced case LPs sell longer-dated exposure without the intended risk compensation.
Recommendation
Bind risk premium reads to the market term. Either make SwapCore consume a tenor-aware risk premium interface and call getRateByTenor(market.swapTerm), or require wrappers used as riskPremiumOracle to expose their configured tenor and reject market creation unless it equals swapTerm.
-
I-01 Informational Virtual share price scales with expired queue Warning Acknowledged
Description
computeVirtualSharePrice()walks the market expiry queue and virtually settles every expired unsettled swap until it reaches the first active swap.KairosMarketAdapter._realAssets()callsSwapCore.getPoolSharePriceVirtual()on chain whenever the adapter reports its current LP value. That makes adapter accounting depend on the number of expired unsettled swaps in the queue. A market with many expired swaps can makerealAssets()expensive enough to threaten allocation, deallocation, and vault accounting calls that need the adapter's value. Since swap creation is permissionless, this creates a liveness dependency on keepers or other users settling expired swaps before the queue grows too far. We ran a local test which opened expired unsettled swaps in two markets and measured the adapter view.KairosMarketAdapter.realAssets()used 588,897 gas with 10 expired swaps and 1,144,513 gas with 50 expired swaps. The exact gas will vary with market state, but the important property is the unbounded queue-length dependency in an on-chain accounting function.Recommendation
Consider adding stronger queue controls: cap open swaps per market, require settlement batches before new activity or make expired-swap cleanup part of the protocol's keeper incentives.
-
I-02 Informational Compound borrow index accrues with zero debt Unexpected Behavior Acknowledged
Description
CumulativeIndexBorrowCompound.getCumulativeIndex()reads Compound'sTotalsBasicstate and projectsbaseBorrowIndexforward wheneverlastAccrualTimeis behind the current timestamp. It does not checkt.totalBorrowBasefirst:uint256 borrowIndex = uint256(t.baseBorrowIndex); if (borrowIndex == 0) return (0, false); uint256 elapsed = block.timestamp - uint256(t.lastAccrualTime); if (elapsed > 0) { borrowIndex = _tryProject(borrowIndex, elapsed); } return (borrowIndex * SCALE_FACTOR, true);That means the adapter can report borrow-index growth even when the Comet market has no outstanding borrow principal. If
getBorrowRate(0)returns a positive rate,_tryProject()applies that rate and returns the projected index as valid. Kairos then treats the value as a cumulative floating index. A listed Compound borrow-index market can therefore mark or settle swaps against synthetic borrow accrual from a period where no Compound borrowers were paying interest.Recommendation
Check
t.totalBorrowBasebefore projecting the borrow index. IftotalBorrowBase == 0, return the storedbaseBorrowIndexwithout projection, or return(0, false)so settlement-critical callers reject the read. The safer choice depends on whether the protocol wants zero-debt periods to mean flat floating accrual or an invalid oracle state. If deployments rely on Compound borrow-index markets never reaching zero debt, enforce that as a listing invariant. Do not leave it as an off-chain assumption. -
I-03 Informational Early exit bypasses liquidation rewards Warning Acknowledged
Description
SwapCore.exitSwapEarly()does not check whether the swap is already liquidatable before it marks the position asisEarlyExitand settles it through_settleSwaps(). That lets the position owner close a liquidatable swap through ordinary early-exit accounting instead of liquidation accounting. This matters becauseUtils.settleSwap()always returns the remaining buyer collateral plus the prefundedliquidationBountyto the buyer on a non-liquidation close. By contrast,Utils.liquidateSwap()pays the buyer-side bounty to the liquidator when the buyer is liquidatable. The same issue exists when the pool side is liquidatable. Liquidation payspoolCollateralBacking * liquidationIncentive / WADto the liquidator and gives the buyer the remaining pool backing. Early exit can instead pay the buyer the full pool backing, so the buyer captures the amount that liquidation reserves for the liquidator. Consequently, the swap owner can front-run liquidation withexitSwapEarly()and take the liquidation reward value. This weakens the keeper incentive that is supposed to make liquidation reliable. It also gives the owner a better close outcome than the liquidation rules allow once the position has already crossed the liquidation threshold.Recommendation
Make early exit reject liquidatable swaps before setting
swap.isEarlyExit.exitSwapEarly()should call the same liquidation check used byliquidateSwap()with a fresh reference index. If either side is liquidatable, revert and require liquidation, or route the close through liquidation accounting so the liquidation reward is paid consistently. The simplest rule is to reject voluntary early exit once liquidation is available:(bool isBuyerLiquidatable, bool isPoolLiquidatable,,) = Utils.isSwapLiquidatable(...); if (isBuyerLiquidatable || isPoolLiquidatable) revert SwapLiquidatable(); -
I-04 Informational Kairos oracles freeze negative-rate markets Logical Error Pending
Description
KairosOracleBeta.setReferenceRate()rejects every non-positive rate. ItsgetRate()function also reports the oracle as invalid when the stored rate is zero or negative. This is incompatible with markets whose valid reference rate can be negative, such as funding-rate markets. The core protocol otherwise supports signed rates.RateIndexLib.update()can accumulate negativeSpotRatevalues and the shared PoC shows that an otherwise identical SpotRate market settles normally when the oracle accepts a negative rate. WithKairosOracleBeta, a negative real-world rate cannot be posted. The market either keeps using the last positive value until it becomes stale, or a newer signed oracle variant can store a negative value thatgetRate()then marks invalid. Once the oracle is invalid, settlement-critical functions that callRateIndexLib.update()revert withE606. Existing swaps can remain unsettled until the rate turns positive again, and expired unsettled swaps block normal market operation.KairosOracle.getRate()has the same non-positive invalidation check, so the signed N-of-M replacement preserves the same incompatibility even though itssetRate()function accepts signed bounds.Recommendation
Allow signed rates in Kairos rate oracles when the configured market supports negative reference rates. Remove the
rate <= 0invalidation fromgetRate()and validate only initialization, freshness and configured absolute bounds. If a deployment intentionally supports only strictly positive rates, enforce that at market configuration time and do not use those oracles for funding-rate or other signed-rate markets. -
I-05 Informational TWAPOracle backfills current spot rate Unexpected Behavior Pending
Description
TWAPOracle.getRateWindow()reads the current spot rate and uses it to calculate a fresh rate index for the entire elapsed interval sincelastUpdate. The function does this even when the spot rate only became true at the end of that interval. This means a transient or recent spot-rate change is backfilled over the full time since the last checkpoint. The returned average is therefore not a true time-weighted average unless updates happen exactly when the underlying spot rate changes. If this oracle is used as a reference-rate source and it is read by markets or wrappers, a current spot spike can inflate or deflate the reported 24-hour rate for the whole gap since the last TWAP update. For example, assume the oracle was last checkpointed one hour ago while the underlying spot rate was 1%, then the spot source jumps to 20% immediately before a market read.getRateWindow(1 hours)will synthesize the whole missing hour at 20% and return roughly 20%, even though that rate was only true at the end of the interval. For the defaultgetRate()path, which readsgetRateWindow(24 hours), a mature oracle history dilutes the same one-hour error into the 24-hour average, but it still treats the missed hour as if the new spot rate applied for the entire hour. With a 1% to 20% move, that can inflate the reported 24-hour rate by roughly(20% - 1%) / 24, about 0.79 percentage points, until the next checkpoint corrects future accrual.Recommendation
Do not calculate historical accrual from a single current spot read. The oracle should update checkpoints at a bounded cadence and only report averages over stored historical index values. If read-time freshness is required, cap the elapsed interval that
getRateWindow()may fill with the current spot rate. When the elapsed interval exceeds that cap, return invalid instead of synthesizing a historical index from the latest spot value. -
I-06 Informational Exact expiry can be early exited Warning Resolved
Description
exitSwapEarly()blocks early exit only whenswapExpiry < block.timestamp. At the exact maturity timestamp,swapExpiry == block.timestamp, so the function still marks the swap asisEarlyExitand settles it through the early-exit calculation. That is a boundary mismatch. Once a swap has reached maturity, it should settle as an ordinary expiry settlement. Treating exact maturity as an early exit can change event data, reward eligibility, accounting labels, and fee treatment for integrations that distinguish normal settlement from early exit.Recommendation
Change the expiry check to reject early exit at exact maturity:
if (swapExpiry <= block.timestamp) revert Errors.E502();At exact expiry, users and adapters should call
makePayment()and receive normal settlement behavior. -
I-07 Informational Spot bucket aggregation biases MTM Warning Acknowledged
Description
calculateBucketUnrealizedPnL()values a whole bucket from averaged bucket state. It derives oneavgEntryTime, one harmonicavgEntryIndexand oneavgFixedRate, then uses those aggregate values as if every swap in the bucket had the same entry conditions. That approximation is exact for the cumulative convention in the tested model, but it is not exact for the spot-rate conventions. ForSpotRateandSpotCompoundRate, the aggregate entry index can produce a small difference from summing the same swaps one by one. Therefore LP share price can include a bounded mark-to-market bias when a bucket contains swaps that entered at different times and the market uses a spot convention. A local quantification test showed zero bias forCumulative, about $2.44 of bias on $10 million notional over 90 days at a 10% sustained rate, about $40 on $10 million over one year at 10%, and about $1,154 in a pathological $100 million, one-year, 20% scenario. This is still a real accounting approximation, but the observed magnitude is low under the current bucket constraints.Recommendation
This is primarily informational. Document that bucket-level MTM is an approximation for spot-rate conventions, including the expected bounded drift under the current bucket-interval constraints.
-
I-08 Informational Referral IDs have 48-bit collision space Warning Pending
Description
ReferralIdGenerator.generateReferralId()comments that it takes 64 bits from the hash. It then converts that value to exactly eight base62 characters. Eight base62 characters do not preserve 64 bits of output space. They encode62^8possible values, which is about 47.6 bits. The fixed-length conversion keeps only the low eight base62 digits, so different 64-bit inputs can map to the same referral ID. The comment overstates the effective collision resistance of the generated ID.Recommendation
Either increase the referral ID length or update the documentation to describe the actual output space. If referral collisions matter operationally, store assigned IDs and reject collisions at registration time instead of relying only on deterministic truncation.
-
I-09 Informational Self-downstream permanently bricks wrapper Warning Resolved
Description
queueDownstreamrejects the zero address and the current downstream, but it does not rejectaddress(this). A wrapper also advertisesIOracleMetadataandIRateOracle, soaddress(this)passes_requireDownstreamInterface. While the old downstream is still active,IOracleMetadata(address(this)).decimals()forwards to the old downstream and passes the decimals check. The same candidate also passesactivateDownstream. During activation,_validateDownstreamHealth(address(this))callsgetRate()beforedownstreamis reassigned. That read still resolves to the old downstream, so activation succeeds. Afterdownstreamis set to the wrapper itself,getRate(),decimals()anddescription()recurse until they run out of gas. Consequently, every consumer using the wrapper loses oracle liveness. The owner cannot repair the wrapper through another queued downstream becausequeueDownstreamfirst reads the current downstream decimals, which now recurses through the wrapper itself.engageEmergencyCutoffalso cannot makegetRate()return(rate, false)becausegetRate()calls the downstream before_gateValid.Recommendation
Reject direct self-reference in every downstream assignment.
if (newDownstream == address(this)) revert SameDownstream();If wrapper-to-wrapper composition is expected, also add a cycle check that rejects a candidate whose downstream chain already reaches the current wrapper. Otherwise, document that wrappers must not be used as downstreams for other wrappers and enforce that rule in deployment tooling.
-
I-10 Informational Cutoff cannot mask downstream reverts Warning Resolved
Description
engageEmergencyCutoff()is documented as forcing wrapper reads to returnisValid = false. In the tenor wrapper, that only happens after the downstream call has already returned.getRateByTenor(),getRates()andgetRate()all callITenorRateOracle(downstream)first and apply_gateValid()afterward. If the active downstream starts reverting after activation, enablingemergencyCutoffdoes not turn those reads into stable invalid responses. The wrapper still bubbles up the downstream failure. Operators may expect the cutoff event to put the wrapper into a controlled invalid state, while consumers continue to see raw downstream reverts instead of(rate, false).Recommendation
If cutoff is meant to provide a stable invalid response, check
emergencyCutoffbefore calling the downstream.getRateByTenor()andgetRate()can return(0, false)andgetRates()can return a zero-filled array with the same length as the requested tenor list plusisValid = false. If raw downstream reverts are acceptable during cutoff, update the wrapper comments and monitoring expectations so operators do not rely on cutoff to mask a failing downstream. -
I-11 Informational Pyth confidence config can overflow Warning Resolved
Description
PythAdapteraccepts any_maxConfRatioBpsvalue and stores it as an immutable configuration.getRate()then evaluatesmaxConfRatioBps * uint64(pythPrice.price)inside the confidence-ratio check. With an excessively large configured ratio, that multiplication can overflow and revert in Solidity 0.8. Even below the overflow boundary, values above a sane basis-point range effectively disable the confidence filter while still appearing configured. This is a deployment-configuration hazard. A bad value can turn a normal oracle read into a hard revert or make the confidence guard meaningless.Recommendation
Validate
_maxConfRatioBpsin the constructor. For example, require it to be zero or less than or equal to10_000, unless the protocol intentionally supports wider ratios. Also consider rewriting the comparison to avoid multiplication overflow if wider limits are required. -
I-12 Informational Risk premiums can cross between sides Warning Acknowledged
Description
RiskRateOracle.postRiskPremiums()validates the upper and lower premium vectors independently. It checks array lengths, signer approval, cadence, bounds and per-side rate-change limits, but it never checks any relationship betweenupRates[i]andloRates[i]. The contract documentation says both vectors come from the same Monte Carlo TWAR distribution and must be co-posted to preserve symmetric-quantile coherence. A signer or pricing-pipeline mistake can therefore post internally inconsistent pairs that pass all on-chain checks. For example, an upper premium below the lower premium can be stored if each side is independently within bounds and rate-change limits.Recommendation
Encode the required relationship between each upper and lower premium pair. If the intended invariant is simple ordering, reject crossed pairs directly.
if (upRates[i] < loRates[i]) revert InvalidPremiumPair();If the pricing model requires a different shape constraint, enforce the weakest critical invariant on-chain and keep the richer model checks in the off-chain pipeline.
-
I-13 Informational Missing source-market caps for rate oracles Warning Acknowledged
Description
_initializeMarket()validates market parameters, checks oracle health and initializes the selected reference-rate index. It does not record which external lending market backs the chosenreferenceRateOracleand it does not enforce any minimum external supply, debt, shares, or source-market depth. That leaves the relationship between Kairos notional and source-market manipulation cost entirely off-chain.Utils.validateMarketParams()checks generic market fields andbuySwap()later limitsnotionalAmountagainst Kairos LP collateral. Neither check compares the open Kairos exposure with the amount of capital needed to move the referenced Aave, Compound or Morpho market. The deployed Base source oracles were sampled on 2026-05-09. External source-market state was read at Base block45762123and liveSwapCoremarket usage was checked at block45762207. The checked-in production market config had no filledPROD_MARKETSentries. The sampled live Kairos markets that matched Aave and Compound source oracles had zero notional and no sampled live market matched the saved Morpho oracle addresses. Therefore this is not evidence of an active exploit. The snapshot still shows why source-market depth should be a listing invariant: In the first table,Utilization at sampleis the source market's current borrow assets divided by supply assets.Capital to reach 95% utilizationis the extra USDC borrow needed to push the source market from its sampled utilization to 95%. This is capital that must be sourced and kept safe, not the amount permanently spent. The carry columns estimate the interest cost of holding that extra borrow for one hour or one day at the target utilization's borrow rate. <table header-row="true"> <tr> <td>Source market</td> <td>Utilization at sample</td> <td>Capital to reach 95% utilization</td> <td>Estimated one-hour carry at 95%</td> <td>Estimated one-day carry at 95%</td> </tr> <tr> <td>Aave V3 Base USDC</td> <td>86.25%</td> <td>15,529,178.18USDC</td> <td>168.4100USDC</td> <td>4,041.8409USDC</td> </tr> <tr> <td>Compound III Base USDC</td> <td>89.92%</td> <td>490,641.34USDC</td> <td>12.3221USDC</td> <td>295.7293USDC</td> </tr> <tr> <td>Morpho Blue Base USDC/cbBTC</td> <td>89.56%</td> <td>70,946,675.99USDC</td> <td>901.4294USDC</td> <td>21,634.3060USDC</td> </tr> <tr> <td>Morpho Blue Base USDC/WETH</td> <td>89.42%</td> <td>4,250,991.74USDC</td> <td>53.9979USDC</td> <td>1,295.9485USDC</td> </tr> </table> The same snapshot also showed the current source-market balances: In the second table,Supply assetsandBorrow assetsare the sampled balances of the underlying lending market, denominated in USDC. These balances provide context for the manipulation-cost estimates above. A downward-rate manipulation could also be modeled by adding supply or repaying debt, but that requires a chosen target utilization and is less directly comparable across protocols. The table therefore keeps only the observed balances. <table header-row="true"> <tr> <td>Source market</td> <td>Supply assets</td> <td>Borrow assets</td> </tr> <tr> <td>Aave V3 Base USDC</td> <td>177,492,726.40USDC</td> <td>153,088,911.91USDC</td> </tr> <tr> <td>Compound III Base USDC</td> <td>9,667,287.75USDC</td> <td>8,693,282.02USDC</td> </tr> <tr> <td>Morpho Blue Base USDC/cbBTC</td> <td>1,304,587,762.43USDC</td> <td>1,168,411,698.31USDC</td> </tr> <tr> <td>Morpho Blue Base USDC/WETH</td> <td>76,220,664.15USDC</td> <td>68,158,639.20USDC</td> </tr> </table> The carry estimate is first-order. It usesextraBorrow * targetAPR * holdSeconds / 365 days, with Aave target rates derived from the live Aave USDC interest-rate strategy, Compound target rates read fromComet.getBorrowRate(targetUtilization)and Morpho target rates read by simulating the market state passed into the configured IRM. It does not include collateral requirements, borrow caps, isolation constraints, liquidation risk, price risk, gas or the opportunity cost of capital. Those figures are not exploit proofs. They are risk thresholds. A Kairos market should not allow open notional large enough that the value gained from a manipulated rate can exceed the cost of creating that rate move in the source lending market. This becomes an exploitable vector when a trader can hold the profitable side of a Kairos swap, move the referenced source market during the rate observation or settlement window, and earn more from the Kairos payout than the cost and risk of the source-market action. In simple terms, the dangerous condition is:Kairos notional * rate move * affected time / 365 days > source-market carry + gas + slippage + liquidation/price-risk costThe risk is highest when the source market is small, already close to the steep part of its interest-rate curve or can be moved with a modest borrow or supply operation. It is also higher when the Kairos oracle reads a live rate, backfills a current rate over a stale interval, uses a short TWAR or can be updated at predictable settlement times. In those cases, the attacker does not need to manipulate the source market permanently. They only need the manipulated rate to be accepted for the window that affects entry pricing, mark-to-market or settlement. The Morpho sources have an additional edge case. If a listed Morpho market is new, thin, or can be emptied by one actor, share-ratio based indexes can move for accounting-state reasons rather than accumulated yield. That can make the source cheaper to manipulate than the large-market utilization numbers above suggest. This is not exploitable in the same way when the Kairos market has no open notional, buying is paused, the source-market move cannot be sustained through the oracle window or the Kairos notional cap is small enough that the maximum rate distortion is below the attacker's expected cost. Deep source markets and long, checkpointed TWARs reduce the risk, but they do not replace explicit notional caps because the safe cap changes as source liquidity changes.
Recommendation
Add source-market risk limits to market listing and ongoing monitoring. For each allowed oracle source, store the underlying protocol, asset or market id, minimum source supply, minimum source debt, minimum Morpho shares where relevant and maximum Kairos open notional. Set the notional cap from a conservative manipulation-cost model for the configured oracle window. Pause new swaps or mark the source invalid when source liquidity falls below the threshold. For Morpho share-ratio sources, also block low-share or empty markets unless a stateful monotonic oracle is used. TWARs and checkpointed sources help, but they should not replace explicit source-market depth checks.
-
I-14 Informational Flat curve extrapolates tenors Warning Partially resolved
Description
BaseTimelockedOracle._interpolateFromMemory()returns the first bucket rate for tenors below the configured curve and the last bucket rate for tenors above it.if (!foundLower) return bucketRates[0]; if (!foundUpper) return bucketRates[len - 1];This means an out-of-range tenor is still reported as a valid oracle value.
SwapCore._initializeMarket()only checks thatgetRateByTenor(params.swapTerm)returnsok == true, so a market whoseswapTermis outside the oracle's configured tenor buckets can pass creation if the oracle serves the endpoint bucket. This is not currently an active long-dated deployment issue in the Base artifacts checked. The deployed fixed-rate curves use 7d, 14d, 30d, 60d, 90d, 180d and 365d buckets. The shorter deployedRiskRateOracle_aave_v3_usdc_borrowcurve stops at 90d, but its deployed sided wrappers are both configured for 30d. The current BaseSwapCoremarket list contains only 1h, 12h, 1d and 7d markets, and we did not find an active market whoseswapTermexceeds the referenced bucketed curve. The residual risk is operational. A future market or wrapper can be configured beyond the curve domain and receive the endpoint rate instead of being rejected. If term risk is expected to increase outside the configured curve, flat endpoint reuse can underprice longer-dated exposure.Recommendation
Make the oracle domain explicit. The safest behavior is to return invalid for tenors below the first bucket or above the last bucket, then require market creation to stay within that domain. If flat extrapolation is intentional, encode that assumption in deployment validation and documentation. Market creation scripts should check
swapTerm <= max(tenorBuckets)for bucketed base-rate and risk-premium oracles and wrapper deployment should reject default tenors outside the downstream curve unless an operator deliberately opts into endpoint reuse. -
I-15 Informational Nonexistent markets report default share price Warning Resolved
Description
getPoolSharePrice()returnsWADwhenevermarket.pool.totalShares == 0before it checks whether the market exists. For an arbitrarymarketIdthat was never created, the defaultMarketstruct hasexists == falseandpool.totalShares == 0. Therefore the function reports a valid-looking1e18share price for a nonexistent market. This can be confusing for frontends, indexers, monitoring and other off-chain consumers that treat a successfulgetPoolSharePrice()response as evidence that the market exists and has a normal initial share price.Recommendation
Reject nonexistent markets before applying the empty-pool default.
function getPoolSharePrice(bytes32 marketId) public view returns (uint256) { Types.Market storage market = markets[marketId]; if (!market.exists) revert Errors.E300(); if (market.pool.totalShares == 0) return WAD; ... }
Remediation Review 2
6 findings · May 26 to 28, 2026-
M-01 Medium Morpho sources can brick base-rate TWAR pokes DoS Resolved
Description
IndexBaseRateV1.poke()andAaveIndexBaseRateV1.poke()require every new cumulative source index to be greater than or equal to the last stored observation. If the source returns any lower value, the poke reverts withIndexNonMonotonic(). That rule is too strict for Morpho share-ratio cumulative adapters.CumulativeIndexBorrowMorphoandCumulativeIndexSupplyMorphodocument that normal Morpho rounding can make the reported share-price index decrease by sub-wei amounts even when the underlying borrow or supply rate is non-negative. The core cumulative-index consumer now clamps decreases before settlement uses them, but the base-rate TWAR oracles do not clamp. They stop accepting pokes. Consequently, a Morpho cumulative source can be usable enough forRateConvention.Cumulativesettlement while still being unsafe as the source for a base-rate TWAR oracle. Once a rounding-driven decrease blockspoke(), the retained observations age out and base-rate reads can become invalid. Markets that depend on that base-rate oracle can then reject swap entry, early exits and LP mark-to-market operations until the source index moves back above the last stored observation or the oracle is replaced.Recommendation
Do not plug raw Morpho share-ratio cumulative adapters into base-rate TWAR oracles that require strict monotonicity. For generic cumulative sources, allow a bounded monotonic clamp in
poke():if (idx < lastIdx) { if (lastIdx - idx > maxAllowedRoundingDrift) revert IndexNonMonotonic(); idx = lastIdx; } -
M-02 Medium Oracle updates can sandwich LP shares MEV Partially resolved
Description
supplyCollateral()updates the reference index, reads the current pool share price and immediately mints LP shares at that price.withdrawCollateral()uses the same current mark-to-market share price for redemption, while its only deposit-age guard blocks withdrawal in the same block as the deposit. The share price is therefore sensitive to discrete oracle updates. If a pending base-rate or reference-index update will increase LP mark-to-market value, an informed actor can deposit before the update and withdraw after the one-block lock at the higher share price. If the pending update will decrease share value, the actor can withdraw before the update and leave the loss with remaining LPs. For example, consider aBUY_FIXEDmarket whosebaseSwapRateOracleisFixedRatePricingOracle, with open swaps and enough idle collateral for withdrawals:- A relayer broadcasts
FixedRatePricingOracle.postForecasts(tenors, baseRates, ciLowers, ciUppers, stdDevs, observedAt, deadline, signatures)with lowerbaseRates. The calldata reveals that the base-rate curve will move down, but the oracle still reports the old 5% curve until the relayer transaction executes. A watcher sendssupplyCollateral()with higher priority so it lands earlier in the same block. The pool share price is still based on the higher projected floating leg, so the watcher receives shares at the pre-update price. - Later in that same block, the relayer's
postForecasts()transaction executes and stores the lower curve. The same active swaps are now marked with a lower projected floating leg, sogetPoolSharePrice()increases and the newly minted shares share in that NAV uplift. - The watcher cannot withdraw in the same block because
lastDepositBlockblocks same-block withdrawal. Instead, they wait one block and callwithdrawCollateral(). The withdrawal burns the short-lived shares at the higher post-update share price. The watcher exits with more collateral than they deposited, while incumbent LPs lose part of the oracle-update gain that belonged to their pre-existing exposure. This is not prevented by the signed-oracle freshness checks. A valid oracle update can still be relayed at a chosen time and LP share minting or burning has no oracle-epoch guard. Consequently, passive LPs can be diluted or left with losses when share accounting changes across a known oracle update.
Recommendation
Consider replacing the one-block LP withdrawal guard with a real cooldown for active markets. When
pool.lockedCollateral > 0,supplyCollateral()should set an unlock timestamp or unlock block for the LP position andwithdrawCollateral()should reject withdrawals until that delay has elapsed. The delay should cover at least the oracle update cadence, so a watcher cannot deposit beforepostForecasts()and redeem immediately after the update lands. - A relayer broadcasts
-
L-01 Low Max live gap can revive stale base-rate quotes Unexpected Behavior Resolved
Description
IndexBaseRateV1usesmaxLiveGapSecto reject stale interpolation spans._realizedTwar()returns invalid when the left anchor sits too far before the TWAR boundary, which prevents a single late poke after a long outage from making a stale-to-live interpolation look usable.setMaxLiveGapSec()lets the owner change that safety bound immediately. The function has no timelock and no upper bound. An owner can therefore widen the accepted gap after an outage and make the same stale quote valid in the same transaction. This is owner-only, so it is not a permissionless attack. It is still a protocol safety issue becausemaxLiveGapSecis an oracle-quality control, not a harmless display parameter. Markets and integrators may rely on the gap bound as the reason stale base-rate quotes fail closed. The current setter lets governance or an operator weaken that guarantee without the notice period used for other sensitive oracle controls.Recommendation
Cap
newMaxLiveGapSecto a conservative multiple oftwarWindowSec, such as2 * twarWindowSecor to a deployment-specific maximum set at construction. Timelock increases tomaxLiveGapSecbecause they make the oracle more permissive. Decreases can remain immediate because they only make stale data harder to accept. Emit the old and new value as the contract already does and document the operational meaning of the configured gap for market integrators. -
L-02 Low Expired adapter swaps keep liquidation haircuts Unexpected Behavior Resolved
Description
KairosBuyerAdapter._realAssets()values every unsettled swap with the same liquidation assumptions. When the buyer is owed value, it caps pool-side recovery atpoolBacking - liquidatorRewardeven if the swap has already expired. When the buyer owes more than its remaining collateral, it reports zero and drops the prefundedliquidationBounty. The code comment treats this post-expiry undercount as conservative. That is not equivalent to normal expiry settlement.Utils.isSwapLiquidatable()returns false onceblock.timestamp >= swap.entryTimestamp + market.swapTerm, so no liquidator can take a reward after expiry.Utils.settleSwap()then settles against full available collateral and returns any remaining buyer collateral plus the prefunded liquidation bounty to the buyer. Consequently, an expired unsettled adapter-owned swap can be worth more at settlement thanrealAssets()reports before settlement. A Vault V2 depositor can mint shares while adapter NAV is understated, then anyone can settle the swap and lift adapter NAV to the realized value. The gap is bounded by the post-expiry liquidation haircut or returned bounty on each affected swap, but it transfers value from existing vault shareholders to deposits made before settlement.Recommendation
Do not duplicate settlement math inside
KairosBuyerAdapter. Instead, add or reuse aSwapCoreview that returns the settlement-equivalent buyer value for an expired unsettled swap, using the same expiry index, caps, direction, collateral release and bounty rules thatmakePayment()would use.KairosBuyerAdapter._realAssets()should call that core preview for expired unsettled swaps. Keep the liquidation-adjusted conservative valuation only while a swap is still live and can still be liquidated. If you want a simpler and more conservative fix, block vault deposits, withdrawals and other adapter valuation-sensitive actions whenever the buyer adapter has expired unsettled swaps. Require those swaps to be settled first, then let_realAssets()value only live swaps and already-realized token or escrow balances. -
L-03 Low Buy swap lacks total input bound Unexpected Behavior Resolved
Description
buySwap()lets callers bound the rate and the markup, but it does not let them bound the total tokens pulled from the buyer. After the slippage checks, the function calculatesrequiredBuyerCollateral, the protocol fee, the prefunded liquidation bounty and then pulls their sum frommsg.sender. ForBUY_FLOATING,rateBoundis a floor onbaseRate. A higher base rate can pass that check because it is favorable for the fixed leg the buyer receives, butcalculateBuyerCollateral()usesabs(baseRate) + utilizationFee + riskPremium. Therefore the same execution can require more posted collateral than the caller expected while still satisfying the on-chain rate bound and markup bound. A user or adapter with a large allowance can enter a position that locks materially more collateral than intended.Recommendation
Add explicit input caps to
buySwap(), such asmaxBuyerCollateralandmaxTotalIn. Enforce them after computingrequiredBuyerCollateral,protocolFeeandliquidationBounty, but before storing the swap and transferring tokens. Adapters should expose and pass through the same bounds. -
L-04 Low Oversized indexes hide bucket floating exposure Unexpected Behavior Resolved
Description
Utils.addSwapToBucket()records each swap's entry index inweightedInverseIndexthroughMath.mulDiv(swap.notionalAmount, INITIAL_INDEX, swap.entryFloatingIndex). If a cumulative reference oracle reports an unusually large index, or a wrapper migration changes the index scale, this term can round down to zero for small token-native notionals. The bucket still records nonzero notional, collateral, pool backing, fixed-rate terms, and entry time. However,calculateBucketUnrealizedPnL()reconstructsavgEntryIndexfromweightedInverseIndexand sets it to zero when the accumulator is zero. The accrued floating rate is then skipped entirely because the code only callsderiveRateFromIndex()whenavgEntryIndex > 0.VirtualShareLibmirrors the same memory-bucket reconstruction for virtual share pricing. Consequently, active bucket mark-to-market can ignore real floating exposure while the swap remains open. LP share price and adapter NAV can then be overstated or understated until settlement removes the swap and prices it individually. This requires a malformed, mis-scaled or poorly migrated cumulative oracle domain; ordinary RAY-style indexes near1e27should not trigger the truncation for normal notionals.Recommendation
Reject swap entry when the bucket inverse contribution would be zero:
uint256 inverseContribution = Math.mulDiv( swap.notionalAmount, SwapFormulas.INITIAL_INDEX, swap.entryFloatingIndex ); if (inverseContribution == 0) revert BucketInverseUnderflow();Also bound the maximum supported cumulative index scale at market creation and wrapper activation. If very large index domains must be supported, store the bucket inverse accumulator with higher precision and downscale only at the final PnL calculation.
Remediation Review 4
66 findings · June 16 to July 9, 2026-
H-01 High Cumulative index basis drains LP backing Unexpected Behavior Reported
Description
IndexBaseRateV1._applyTenorBump()converts a trailing cumulative-index TWAR into an exact-exponential tenor quote:baseRate(T) = (exp(r * T / YEAR) - 1) * YEAR / TThat generic compounding model does not match every cumulative source that can be paired with the oracle. The Aave adapters settle against Aave's supply and borrow index formulas. The Compound adapters settle against Comet's simple
accruedInterestIndices()projection. The Morpho adapters settle against Morpho's three-term Taylor projection and, for supply, fee-share dilution. When aBUY_FLOATINGmarket usesIndexBaseRateV1over one of theseCumulativeIndex*adapters as the base oracle and uses the same adapter as the cumulative reference oracle,SwapCore.buySwap()stores the exact-exp quote asswap.baseRate. At settlement, the floating leg is derived from the lower source-specific cumulative index growth. The buyer therefore receives a fixed leg that the reference index cannot realize. The issue affects the in-scope Aave, Compound and Morpho cumulative borrow and supply adapters:CumulativeIndexBorrowAaveCumulativeIndexSupplyAaveCumulativeIndexBorrowCompoundCumulativeIndexSupplyCompoundCumulativeIndexBorrowMorphoCumulativeIndexSupplyMorphoThe local PoCs use two-yearBUY_FLOATINGmarkets, 18x leverage, high source rates and 10,000,000 USDC of LP backing. In every tested adapter pairing, the stored exact-exp base rate exceeded the floating rate later derived from the same cumulative reference source. Settlement paid the buyer their posted collateral, the liquidation bounty and the full 10,000,000 USDC of locked LP backing. The full-LP-drain impact is configuration-sensitive. LetBbe the annualizedIndexBaseRateV1exact-exp base rate,Rbe the annualized floating rate later derived from the cumulative reference index,FbeutilFee + riskPremiumandLbe the market leverage multiplier. With a max-sizedBUY_FLOATINGswap and ignoring integer rounding, the condition for full LP backing loss is:
L * (B - R - F) / B >= 1Equivalently, full loss requires
F <= B - R - B / L. Any buyer-positive settlement only requiresF < B - R. The per-adapter sections below give the concrete model used to computeRand the exact requirements observed in the PoCs. Adapter Analysis- CumulativeIndexBorrowAave
CumulativeIndexBorrowAaveexposes Aave V3'sgetReserveNormalizedVariableDebt(asset)as the cumulative reference index. The adapter intentionally follows Aave's projected variable debt index, which uses Aave's binomial approximation instead of exact exponential compounding. The issue appears when the same Aave borrow adapter is used as theIndexBaseRateV1source and as the market's cumulative reference oracle.IndexBaseRateV1observes the trailing Aave borrow-index growth, converts it into a continuous APR and then quotes the full swap tenor with exactexpWad. Settlement later derives the floating leg from Aave's lower projected debt index. In the PoC, the stored exact-exp base rate was3542457441174355189WAD while the Aave borrow floating rate was2897811585586913779WAD. The spread was enough to pay the buyer all locked LP backing. For this adapter, computeRfrom Aave's normalized-variable-debt factor overnseconds:
AaveBorrowFactor = 1 + n * y + n * (n - 1) * y^2 / 2 + n * (n - 1) * (n - 2) * y^3 / 6 y = variableBorrowRatePerSecond R = (AaveBorrowFactor - 1) / (n / YEAR)With no fees, 18x leverage and a 2-year term, full backing loss starts when
variableBorrowRate * term / YEAR >= 1.2674196508444446, which is about63.37%APR for two years. In the PoC,variableBorrowRate * term / YEAR = 2.09. The minimum no-fee leverage for the PoC rates was5.4952x; at 18x leverage, fees and risk premium could be as high as0.447842664411088e18annualized before full LP backing loss stopped.- CumulativeIndexSupplyAave
CumulativeIndexSupplyAaveexposes Aave V3'sgetReserveNormalizedIncome(asset)as the cumulative reference index. This is the liquidity index used for suppliers. It is a supply-side index and can grow more slowly than borrow indexes because supply yield is reduced by utilization and reserve factors. The same basis exists when this supply adapter feedsIndexBaseRateV1and also settles the floating leg.IndexBaseRateV1turns the observed supply-index growth into an exact-exp tenor quote. Settlement uses the actual Aave supply-index growth. In the PoC, the stored exact-exp base rate was3536420647291896271WAD while the Aave supply floating rate was1045000000000000000WAD. The buyer therefore received a fixed leg far above what the Aave supply index realized and drained all locked LP backing. For this adapter, computeRfrom Aave's linear liquidity-index factor:
AaveSupplyFactor = 1 + liquidityRate * term / YEAR R = liquidityRateWith no fees and 18x leverage, full backing loss starts when
liquidityRate * term / YEAR >= 0.11324817937107448. That is about5.6624%APR for a 2-year term,11.3248%APR for a 1-year term or22.6496%APR for a 6-month term. In the PoC,liquidityRate * term / YEAR = 2.09. The minimum no-fee leverage for the PoC rates was1.4195x; at 18x leverage, fees and risk premium could be as high as2.2949528335534577e18annualized before full LP backing loss stopped.- CumulativeIndexBorrowCompound
CumulativeIndexBorrowCompoundreads Comet'sbaseBorrowIndexand projects it forward with the same simple-interest formula Comet uses inaccruedInterestIndices():
projected = borrowIndex + borrowIndex * borrowRate * elapsed / 1e18This is correct for Compound V3 settlement, but it is not the model used by
IndexBaseRateV1. ABUY_FLOATINGmarket that quotes from this adapter with exact exponential compounding and settles against the same simple-projected borrow index gives the buyer a fixed leg that the Compound borrow index cannot match over an idle interval. In the PoC, the stored exact-exp base rate was3536420647279726867WAD while the Compound borrow floating rate was1044999999998927949WAD. The settlement drained all locked LP backing. For this adapter, computeRfrom Comet's simple borrow-index projection:CompoundBorrowFactor = 1 + borrowRatePerSecond * term R = borrowRatePerSecond * YEARWith no fees and 18x leverage, full backing loss starts when
borrowRatePerSecond * term >= 0.11324817937107448. Equivalently,annualizedBorrowRate * term / YEARmust be at least0.11324817937107448. That is about5.6624%APR for a 2-year term,11.3248%APR for a 1-year term or22.6496%APR for a 6-month term. In the PoC,annualizedBorrowRate * term / YEAR = 2.09. The minimum no-fee leverage for the PoC rates was1.4195x; at 18x leverage, fees and risk premium could be as high as2.2949528335430363e18annualized before full LP backing loss stopped.- CumulativeIndexSupplyCompound
CumulativeIndexSupplyCompoundis the supply-side version of the same Comet integration. It readsbaseSupplyIndexand projects it with Comet's simple supply-interest formula:
projected = supplyIndex + supplyIndex * supplyRate * elapsed / 1e18When
IndexBaseRateV1uses this adapter as its source, it converts the observed simple supply-index growth into an exact-exp tenor quote. The market then settles from the lower simple-projectedbaseSupplyIndex. In the PoC, the stored exact-exp base rate was3536420647279726867WAD while the Compound supply floating rate was1044999999998927949WAD. The buyer received all locked LP backing. For this adapter, computeRfrom Comet's simple supply-index projection:CompoundSupplyFactor = 1 + supplyRatePerSecond * term R = supplyRatePerSecond * YEARWith no fees and 18x leverage, full backing loss starts when
supplyRatePerSecond * term >= 0.11324817937107448. Equivalently,annualizedSupplyRate * term / YEARmust be at least0.11324817937107448. That is about5.6624%APR for a 2-year term,11.3248%APR for a 1-year term or22.6496%APR for a 6-month term. In the PoC,annualizedSupplyRate * term / YEAR = 2.09. The minimum no-fee leverage for the PoC rates was1.4195x; at 18x leverage, fees and risk premium could be as high as2.2949528335430363e18annualized before full LP backing loss stopped.- CumulativeIndexBorrowMorpho
CumulativeIndexBorrowMorphoexposes Morpho Blue's borrow share exchange rate. If the Morpho market state is stale, the adapter projectstotalBorrowAssetswith Morpho's three-term Taylor approximation:
wTaylorCompounded(x) = x + x^2 / 2 + x^3 / 6The exact-exp quote in
IndexBaseRateV1is higher than the Taylor-projected borrow-index growth used by settlement. A same-sourceBUY_FLOATINGmarket therefore stores a fixed leg above the Morpho borrow index's realizable growth. In the PoC, the stored exact-exp base rate was3542457581112360799WAD while the Morpho borrow floating rate was2897802416661012885WAD. The settlement paid the buyer all locked LP backing. For this adapter, computeRfrom Morpho's Taylor borrow-asset projection:MorphoBorrowFactor = 1 + x + x^2 / 2 + x^3 / 6 x = borrowRatePerSecond * term R = (MorphoBorrowFactor - 1) / (term / YEAR)With no fees and 18x leverage, full backing loss starts when
borrowRatePerSecond * term >= 1.2674197525974966. Equivalently,annualizedBorrowRate * term / YEARmust be at least1.2674197525974966, which is about63.37%APR for two years. In the PoC,annualizedBorrowRate * term / YEAR = 2.09. The minimum no-fee leverage for the PoC rates was5.4952x; at 18x leverage, fees and risk premium could be as high as0.4478519655006615e18annualized before full LP backing loss stopped.- CumulativeIndexSupplyMorpho
CumulativeIndexSupplyMorphoexposes Morpho Blue's supply share exchange rate. Its projection first computes borrower interest with the same Taylor approximation, then adds that interest to supply assets. If the market charges fees, the adapter also mints fee shares, diluting suppliers. This makes the supply adapter especially incompatible with a generic exact-exp tenor quote.IndexBaseRateV1treats observed supply exchange-rate growth as if it can be extended with exact exponential compounding. Settlement instead uses Morpho's Taylor-projected supply assets and fee-share dilution. In the PoC, the stored exact-exp base rate was3542457581112360799WAD while the Morpho supply floating rate was2897802416661012885WAD. The buyer drained all locked LP backing. For this adapter, computeRfrom the projected supply exchange-rate factor:
interest = totalBorrowAssets * (x + x^2 / 2 + x^3 / 6) x = borrowRatePerSecond * term projectedSupplyAssets = totalSupplyAssets + interest projectedSupplyShares = totalSupplyShares + feeShares MorphoSupplyFactor = projectedExchangeRate / entryExchangeRate R = (MorphoSupplyFactor - 1) / (term / YEAR)With 100% utilization, zero Morpho fee, no Kairos fees and 18x leverage, this reduces to the same Taylor threshold as the borrow adapter: full backing loss starts when
borrowRatePerSecond * term >= 1.2674197525974966, about63.37%APR for two years. In the PoC,annualizedBorrowRate * term / YEAR = 2.09. The minimum no-fee leverage for the PoC rates was5.4952x; at 18x leverage, fees and risk premium could be as high as0.4478519655006615e18annualized before full LP backing loss stopped. Lower utilization reduces the supply exchange-rate growth and therefore requires a higher borrow-rate-times-term product. Morpho fee-share dilution reduces the supply exchange-rate growth at settlement, so it must be included inMorphoSupplyFactorrather than treated as a protection by itself.Recommendation
Do not use generic exact-exponential
IndexBaseRateV1tenor quotes for markets settled from protocol-specific cumulative indexes unless the quote model matches the reference index model. The safest fix is to add a listing and wrapper-retarget guard that rejectsBUY_FLOATINGmarkets whereIndexBaseRateV1is sourced from the sameCumulativeIndex*adapter used as the cumulative reference oracle. If those markets are required, deploy source-specific base-rate oracles that quote tenors with the same model used by settlement: Aave's index math for Aave adapters, Comet'saccruedInterestIndices()formula for Compound adapters and Morpho's Taylor compounding plus supply fee-share dilution for Morpho adapters. -
H-02 High Interim liquidation drains LP backing Math Reported
Description
calculateLiquidationObligation()values a live swap by deriving the elapsed floating rate and then passing the elapsedswapDurationinto_calculatePayments(). ForBUY_FLOATING,_calculatePayments()uses the storedswap.baseRateas the fixed leg andSwapFormulas.calculateSwapPayment()prorates that annual rate linearly over the elapsed time. That is unsafe for markets whose base oracle returns a full-tenor compounded-equivalent quote.IndexBaseRateV1._applyTenorBump()returns(exp(r * T / YEAR) - 1) * YEAR / Tfor the whole swap term. At maturity this matches an exact exponential cumulative reference. Before maturity, however, the liquidation check values the fixed leg as(t / T) * (exp(r * T / YEAR) - 1)while the cumulative floating leg realizesexp(r * t / YEAR) - 1. The first expression is larger for positive convex growth, so the pool can appear liquidatable even though the full-term swap is fairly priced. A local test used an exact exponential cumulative reference, the realIndexBaseRateV1andSwapCore. The stored full-term base rate was3542457582152521274WAD and the maturity floating rate was3542457582152530087WAD, showing the maturity quote is effectively fair. At half term, the buyer self-liquidated a maximum-sizedBUY_FLOATINGswap and received their prefunded collateral plus all10,000,000USDC of LP backing, leavingtotalCollateralandlockedCollateralat zero. This is distinct from the prior cumulative-index basis findings because it does not require Aave, Compound or Morpho to realize less than the exact exponential quote at maturity. The loss comes from applying a full-tenor compounded-equivalent rate as a simple pro-rata accrued fixed payment during liquidation.Recommendation
Do not use the full-tenor compounded-equivalent
baseRatedirectly for interim liquidation accrual. Store or derive a rate representation that is linear over elapsed time or compute the elapsed fixed leg from the same compounded curve and elapsed duration used by the base oracle. ForIndexBaseRateV1-backed markets, the interim fixed payment should useexp(r * elapsed / YEAR) - 1, notelapsed / swapTerm * (exp(r * swapTerm / YEAR) - 1). -
H-03 High Dead-oracle fallback drains LP backing Unexpected Behavior Reported
Description
Normal-expiry settlement now has a dead-oracle recovery path:
_settleSwaps()first triesupdateMarketRateIndex()and then callsUtils.resolveExpiryIndex()when no snapshot exists at the swap maturity. AftersettlementGracePeriodSec, if the guaranteed retry still cannot read the oracle,resolveExpiryIndex()settles withRateIndexLib.getIndexAt(rateIndex, refOracle, actualExpiry)and terminates the market. That fallback value is not a conservative last-known-good index. When maturity is after the latest stored snapshot,getIndexAt()calls_extrapolate()and linearly extends the slope between the last two pre-failure snapshots to the maturity timestamp. A short-lived pre-outage trend can therefore be projected across the missing interval and used as the final floating-leg settlement value even though no real oracle data exists at or after maturity. This turns a reference-oracle outage into value transfer rather than only liveness recovery. A buyer whose position benefits from the stale trend can wait until the grace window elapses and permissionlessly settle against the extrapolated index. If the extrapolated net obligation exceedsswap.poolCollateralBacking, settlement pays the buyer the full LP backing cap and clears the lock, even when a last-known-index fallback would have paid far less. LPs can lose the full collateral backing of affected swaps during a reference-oracle outage. The loss is capped per swap bypoolCollateralBacking, but an attacker or exposed buyer can size positions against available market liquidity before the outage and settle all eligible in-the-money positions after the grace period. The fallback also permanently terminates the market, so the loss is realized as part of recovery rather than blocked by the old fail-closed behavior. For this issue to occur, the oracle does not need to be malicious. The precondition is an outage after at least two pre-maturity snapshots and before any snapshot at or after maturity. The last pre-outage slope only needs to be buyer-favorable enough that extrapolating it to maturity exceeds the LP backing cap. This is realistic for cumulative/index references during volatile rate periods or integration outages. Attack scenario:- A BUY_FIXED position is opened while LP liquidity is available.
- Before maturity, the reference index moves upward and
updateMarketRateIndex()records a pre-failure snapshot. - The reference oracle becomes unreadable before any post-maturity snapshot can be written.
- Settlement reverts with
E450untilexpiry + settlementGracePeriodSec. - After grace,
resolveExpiryIndex()retries the oracle, observes the read still fails and settles fromgetIndexAt()'s extrapolated maturity index. - The buyer receives the capped LP backing even though the last known snapshot alone would not justify that payout.
Recommendation
Keep the post-grace recovery so a dead reference oracle cannot permanently pin expired swaps, but separate recovery from valuation. When the adjudicated retry fails and
lastUpdate < actualExpiry,resolveExpiryIndex()should not callRateIndexLib.getIndexAt()because that function extrapolates from the last two snapshots. Use a dedicated no-extrapolation fallback instead. The simplest safe option is to settle from the latest real snapshot at or before the oracle failure and then terminate the market for new swaps. If the protocol wants a fairer recovery value, add a one-shot governance or guardian adjudication that publishes a final expiry index after the grace period. That value should be immutable per(referenceOracle, actualExpiry), reused for every swap with that maturity, emitted in an event and bounded against the last real snapshot by an explicit maximum drift. A real oracle update must always take precedence over the fallback. -
H-04 High Early exit drains LP backing via linear accrual Math Reported
Description
SwapCore.exitSwapEarly()is a public settlement path for swap owners and authorized callers. It marks each requested swap as an early exit and delegates to_settleSwaps(), which fetches a fresh remaining-tenor base rate and reference index before callingUtils.settleSwap(). For early exits,Utils.calculateEarlyExitSettlement()computes both the accrued-to-date leg and the projected remaining leg through_calculatePayments():(int256 accruedFixedPayment, int256 accruedFloatingPayment) = _calculatePayments( market, swap, swapDuration, currentFloatingRate );For
BUY_FLOATING,_calculatePayments()always values the fixed leg by linearly prorating the stored full-tenorswap.baseRateover the requested duration:fixedPayment = SwapFormulas.calculateSwapPayment(swap.notionalAmount, swap.baseRate, duration, 0, 0);That is inconsistent with the protocol's current compounded-accrual handling for cumulative markets.
IndexBaseRateV1advertises that its tenor quotes are full-tenor compounded-equivalent rates and_calculateAccruedPayments()explicitly switches toSwapFormulas.accruedFixedLeg()for cumulative markets whose base oracle advertisesisCompoundedTenorQuote(). The source comments state the reason: straight-line proration of a compounded-equivalent quote overstates interim fixed-leg value before maturity. The early-exit path still bypasses_calculateAccruedPayments()and uses the old straight-line accrued leg. In an early-exit-enabledBUY_FLOATINGcumulative market backed by a compounded-equivalent base oracle such asIndexBaseRateV1, the buyer can wait until the remaining-tenor quote moves down enough forprojectedFavorsBuyerto become true. The remaining leg is then forfeited and only the accrued leg is settled. At that point the overstated straight-line accrued fixed leg is no longer offset by any remaining-term calculation, so the buyer can call the publicexitSwapEarly()path and receive LP backing solely from the linear-vs-compounded accrued-leg gap. The local PoC opens a one-yearBUY_FLOATINGswap against a compounded-equivalent full-term quote, advances half the term and sets the cumulative reference index to the matching exact exponential half-term value.getSwapNetAmount()reports a near-zero accrued mark under the corrected compounded-accrual logic. After the remaining-tenor base quote is lowered to zero, the buyer callsexitSwapEarly()and receives posted margin plus the full locked LP backing. The pool collateral falls by that backing amount while LP shares are unchanged. This is direct token loss from the affected pool's collateral balance; it scales with notional and locked LP backing, with blast radius bounded by affected market liquidity and open-position backing.Recommendation
Make early-exit settlement split compounded-equivalent fixed legs consistently, but do it in a way that preserves the existing full-term settlement invariant. The accrued component should use the same logic as
_calculateAccruedPayments()when the market is cumulative and the base oracle advertises a compounded-equivalent quote. The remaining fixed component should not be computed by calling the same compounded-accrual helper overtimeRemaining; that would value the remaining interval as a fresh curve from zero and would not necessarily add back to the contractual full-term fixed payoff. Instead, derive the remaining fixed leg as:fullFixedPayment = fixed leg over market.swapTerm using the stored entry fixed rate; remainingFixedPayment = fullFixedPayment - accruedFixedPayment;For
BUY_FIXED,fullFixedPaymentshould match the current maturity settlement fixed leg usingswap.swapRate. ForBUY_FLOATING, it should use the storedswap.baseRate. This preserves the invariant:correctedAccruedFixed + remainingFixed == fullTermFixedThen keep the existing early-exit rule:
- if the remaining term favors the buyer, forfeit the remaining leg and settle only the corrected accrued fixed/floating payments;
- if the remaining term favors the pool, include
remainingFixedPaymentand the existing projected remaining floating leg, including the cumulative floating cross-term. For minimum behavioral risk, this corrected split can be applied only whenconvention == Types.RateConvention.Cumulative && _baseQuoteCompounded(market.baseSwapRateOracle). For all other markets, the existing linear split can be preserved exactly except for harmless rounding-dust improvements if the team chooses to consolidate the code path. Also update early-exit event/accounting values to use the same corrected fixed/floating split, or return the gross fixed/floating totals fromcalculateEarlyExitSettlement(). Otherwise settlement can be fixed whileSwapClosedcontinues to report the old straight-line accrued fixed amount for early exits.
-
H-05 High LP adapter NAV ignores liquidation floor Unexpected Behavior Reported
Description
KairosMarketAdapter._realAssets()values the vault's LP position asshares * swapCore.getPoolSharePriceVirtual(marketId) / WAD. That share price can still value live pool-side-liquidatable swaps through the normal active-bucket mark. The bucket mark is capped attotalPoolBacking, but it does not switch to the value that would remain after an immediately executableliquidateSwap(). If a live swap becomes pool-side liquidatable before liquidation is executed, a Morpho-style vault can price shares from idle cash plus an LP adapter NAV that is above the liquidation floor. This window is realistic because liquidation is permissionless, vault share pricing reads adapter NAV before liquidation is mined and redemptions can be served from idle vault cash without unwinding the Kairos LP position first. A fast shareholder can therefore redeem at the inflated vault share price, receive too much idle cash and leave the crystallized liquidation loss with the shareholders who remain in the vault. In the validated scenario, a vault has 50,000,000 USDC from a victim, 10,000,000 USDC from an attacker and a 10,000,000 USDC LP allocation throughKairosMarketAdapter. After a max-sized swap becomes pool-side liquidatable, the adapter reports 22,109,999.999998 USDC of NAV even though immediate liquidation leaves only 0.000001 USDC in the pool. The attacker redeems before liquidation for 12,018,333.333333 USDC. At the liquidation floor, the same shares are worth 8,333,333.333333 USDC, so the attacker extracts 3,685,000 USDC-equivalent from the remaining vault shareholder. This root cause was already flagged in the resolution section of M-10 and acknowledged there as the remaining open pool-liquidation side: bucket share pricing still caps LP losses against fullbucket.totalPoolBackingeven though pool-side liquidation removes value for the liquidator. This writeup should therefore be treated as a stronger impact evidence for that acknowledged issue, not as a separate root cause. The additional evidence is that the LP adapter/Morpho vault surface, whereKairosMarketAdapter._realAssets()feeds the overstated share price into redeemable vault NAV and lets idle cash be withdrawn before liquidation crystallizes the loss.Recommendation
Fix the shared LP share-price calculation rather than special-casing only the adapter. The accounting price used by
SwapCore.getPoolSharePrice(),SwapCore.getPoolSharePriceVirtual(), LP mint/burn paths andKairosMarketAdapter._realAssets()should be derived from the value that can survive the protocol's currently executable close path. For each live unsettled swap or an equivalent conservative per-swap valuation path, reuse the same accrued obligation and liquidation predicate used byUtils.isSwapLiquidatable(). If the pool side is liquidatable, value that swap as a full LP loss ofswap.poolCollateralBacking, becauseUtils.liquidateSwap()removes the entire backing from the pool:poolBacking - liquidatorRewardis paid to the buyer andliquidatorRewardis paid to the liquidator. If the buyer side is liquidatable, value the swap as an LP gain ofswap.collateralBalance, because the buyer's liquidator bounty is prefunded outsidecollateralBalance. Non-liquidatable swaps should still be capped by the settlement amounts that can actually be collected. Apply this rule consistently in both the storage share-price path inUtilsand the memory/virtual path inVirtualShareLib. If exact per-swap liquidation-aware pricing is too expensive for on-chain accounting, fail closed instead: make LP minting, LP burning and adapter NAV reads revert until executable pool-side liquidations are processed or use a deliberately conservative value that cannot exceed the immediate post-liquidation pool value. Do not allow vault share minting or redemption to continue from the optimistic active-bucket mark once liquidation can be executed immediately. -
H-06 High Force-dealloc forfeits buyer liquidation value Validation Reported
Description
KairosMarketAdapter.deallocate()allows a permissionlessforceDeallocate()caller to make a partial LP withdrawal wheneverfullAmountis false and the profit-vest cap is not binding. That guard is too narrow. It does not cover a live swap that is already buyer-liquidatable whilegetPoolSharePrice()is still below the value the pool would have immediately after liquidation. This happens because active LP MTM is computed from projected full-term bucket payments and only capped by aggregate buyer collateral. It does not check whether a specific buyer can already be liquidated. In contrast,Utils.liquidateSwap()transfers all of the buyer's posted collateral to the pool and removes the swap from bucket accounting. Therefore the current share price can understate the pool's immediately executable recovery. A co-LP can callforceDeallocate()before liquidation and choose a partialassetsamount.SwapCore.withdrawCollateral()burns the vault adapter's shares at the understated share price and returns exactly that amount to the vault. The caller then liquidates the buyer-side swap, causing the recovery to be distributed over fewer vault shares and more remaining co-LP shares. A focused Foundry PoC intest-foundry/zz_POCs/BuyerSideLiquidationForceDeallocatePoc.t.solvalidates the issue. In the test, a vault has a 5,000,000 USDC LP allocation and an outside co-LP also has 5,000,000 USDC in the same market. A third-party buyer opens a 500,000,000 USDC notional swap. After two days the buyer is liquidatable, butgetPoolSharePrice()is only0.945205479452while immediate buyer liquidation would price the pool at1.0552186151248. The outside co-LP callsforceDeallocate()for2,363,013.698630USDC, burning half of the vault adapter's shares at the stale-low mark. After liquidation, the vault holds only5,092,737.849502USDC of cash plus LP value. The burned share slice would be worth2,729,724.150872USDC at the final attack-branch share price, so the vault receives366,710.452243USDC less than that slice's final value. The same PoC also runs a clean no-force counterfactual from the exact same pre-liquidation state. If liquidation executes without the forced burn, the vault ends with5,276,093.075624USDC of LP value. Under the force-deallocate sequence it ends with5,092,737.849502USDC of cash plus LP value, so vault depositors are183,355.226122USDC worse off while the force-deallocating co-LP's remaining LP shares are183,355.226121USDC better off. The buyer and liquidator outcomes are unchanged because liquidation still consumes the same buyer collateral and pays the same prefunded bounty. Prior force-deallocation findings cover different root causes: vest-cap forfeiture, dust share-burn rounding or fully exiting through a partial request. Those checks compare the burned shares against the current share price. They do not address this case because the current share price itself omits an immediately executable buyer-side liquidation recovery.Recommendation
Make permissionless
forceDeallocate()fail closed while the adapter's market contains any live buyer-liquidatable swap whose immediate liquidation recovery would increase LP share price. The same liquidation-aware valuation should be used byUtils.calculateBucketUnrealizedPnL()andVirtualShareLib, so LP deposits, withdrawals and adapter NAV all reflect executable buyer-side liquidations before shares can be minted or burned. If exact per-swap liquidation-aware pricing is too expensive, use a conservative guard forKairosMarketAdapter: block partialforceDeallocate()whenever an active market may contain executable liquidations and require allocator-controlled deallocation after liquidation processing. -
M-01 Medium LP MTM Omits Cumulative Cross-Term Logical Error Resolved
Description
Active LP share pricing still omits the Cumulative-convention remaining floating-leg cross-term that early-exit settlement now applies. Under the Cumulative convention, index growth is multiplicative. The accrued leg is priced from entryIndex to currentIndex, while the remaining leg is projected from the base rate. To match the full-term floating leg that settlement realizes, the fee-free remaining floating leg must be scaled by the already-realized currentIndex / entryIndex growth. The recent fix for “Early exits omit current index multiplier” added this term to calculateEarlyExitSettlement(), but active LP MTM still uses the pre-fix composition. calculateBucketUnrealizedPnL() adds accruedFloatingPayment + remainingFloatingPayment directly. The virtual share-price path used by KairosMarketAdapter.realAssets() mirrors the same logic in VirtualShareLib, so Morpho LP adapter NAV inherits the stale valuation while buckets are active. This misprices LP shares until early exit or settlement realizes the corrected value. In BUY_FLOATING markets with positive cumulative growth and positive projected remaining rates, shares are understated, letting new LP deposits mint too many shares and dilute existing LPs once hidden value realizes. In BUY_FIXED markets, the sign flips: shares are overstated, allowing withdrawals against inflated NAV before remaining LPs realize the loss. For example, if the cumulative index has already grown from 1.0x to 1.149x, and the remaining projected floating rate is another 14.95%, the full floating leg should compound to 1.149 * 1.1495 = 1.3207755. The LP MTM path instead adds the two pieces linearly as 14.9% + 14.95% = 29.85%, missing the 2.22755% cross-term.
Recommendation
Apply the same Cumulative cross-term adjustment in active bucket LP MTM and the VirtualShareLib memory path. Recompute the fee-free remaining floating base, add cumulativeRemainingCrossTerm(remainingBase, avgEntryIndex, oracleIndex, convention), and keep util/risk fee accrual simple as settlement does.
-
M-02 Medium Stale anchors let top-ups bypass vesting Unexpected Behavior Resolved
Description
SwapCorestores only one profit-vesting anchor for each LP position. When an LP tops up a position that still has any shares and is still inside its vesting window,supplyCollateral()mints the new shares at the current share price but keeps the oldentryPrice. That is unsafe when the old anchor is higher than the current share price. The top-up receives shares at the lower price, but those shares inherit the old higher anchor.withdrawCollateral()only applies the vesting cap when the current share price is aboveentryPrice, so any recovery from the top-up price up to the old anchor is treated as uncapped value. Consequently, fresh LP capital can earn and withdraw recovery gains during its vesting window simply by joining an existing position with a stale high anchor. A standalone deposit at the same lower share price would have been anchored to that lower price and capped during the same recovery. This lets the topping-up LP dilute other LPs' exposure to the recovery that came from pre-existing pool risk. For example, an LP deposits when the share price is1.0, then the pool moves down while the LP is still vesting. The LP tops up at the lower price, but because the position still has shares,entryPriceremains1.0. If the pool later recovers to0.99, the cap is still inactive because0.99 < 1.0. The fresh top-up shares nevertheless gained value from their lower mint price and that gain can be realized before the fresh capital has actually vested.Recommendation
Track profit vesting per deposit tranche. Existing shares can keep their previous
entryPriceand vest expiry, but newly minted top-up shares should receive the currentsharePriceand their own vest expiry. If per-tranche accounting is too heavy, use a conservative aggregate basis that does not let below-anchor top-ups inherit a higher anchor. For example, store a share-weighted entry price for the combined position, rounded in the pool's favor or reject mid-vest top-ups when the currentsharePriceis below the storedentryPrice. The fix should still prevent upward anchor ratchets, but it should not let fresh capital use stale high anchors. -
M-03 Medium Force-deallocation can burn capped LP shares Unexpected Behavior Resolved
Description
KairosMarketAdapter.deallocate()allows the parent Vault V2 to callACTION_WITHDRAWthrough both allocator-controlleddeallocate()and permissionlessforceDeallocate(). The adapter blocksfullAmount=truewhen the call comes fromforceDeallocate(), but it still acceptsfullAmount=falsewith any nonzeroassetsvalue. That guard is incomplete when the adapter's Kairos LP position is inside an active profit-vest window. During that window,KairosMarketAdapter._realAssets()reports the value the adapter could realize immediately, not the uncapped mark-to-market value. If the current share price is above the position'sentryPrice,_realAssets()floors the share price toentryPrice.SwapCore.withdrawCollateral()applies the same cap before deciding whether the requestedamountis large enough to redeem the whole position. This means a publicforceDeallocate()caller can passfullAmount=falsewhile settingassetsequal to the adapter's capped full-position value. The adapter forwards that amount toSwapCore.withdrawCollateral(). Since the amount is equal to the capped full value,SwapCoretreats it as a full withdrawal and burns all of the adapter's LP shares at the capped price. Consequently, an outsider can force a Morpho vault to exit its Kairos LP position at the vest-capped value even when the fair mark-to-market share price is higher. The vault loses exposure to any value above the cap and cannot wait for the vest window to expire. If the caller or an affiliated account is also a remaining LP in the same market, the value not paid to the vault stays in the pool and can accrue to the remaining LP shares.Recommendation
Do not allow permissionless
forceDeallocate()to crystallize a cap-active Kairos LP position. The adapter should rejectACTION_WITHDRAWthroughforceDeallocate()when the position is still vesting and the uncapped share price is aboveentryPrice. If a public force-deallocation mechanism is required, make it explicitly bounded. A permissionless caller should not be able to burn all adapter shares through the exact-amount withdrawal interface unless an authorized allocator supplied the relevant slippage and share-burn limits. A simpler conservative fix is to reject permissionless withdrawals that would redeem the adapter's entire Kairos LP balance. -
M-04 Medium Morpho bad debt freezes supply-rate high-water Oracle Resolved
Description
CumulativeIndexSupplyMorphoreports the Morpho supply share exchange rate as a cumulative index. That share ratio can decrease after real supply-side losses, including bad debt.IndexBaseRateV1then clamps any lower source index to the previously stored value in bothpoke()and_realizedTwar(). That clamp is useful for tiny rounding drift, but it treats a material supply loss the same way. If bad debt reduces the Morpho supply share ratio, the base-rate oracle keeps using the old pre-loss high-water mark. Later supply yield is not recognized until the share ratio climbs above that old high. Consequently, markets using the affected Morpho supply base-rate oracle can receive stale or understated supply-rate quotes after a real supply share-price decrease. The oracle hides the loss and also hides real post-loss recovery below the old high-water mark. This can distort swap pricing, LP mark-to-market and any integration that expects the base-rate oracle to reflect realized Morpho supply yield. For example, if the Morpho supply share ratio reaches1.10xand later falls to1.05xafter bad debt,IndexBaseRateV1continues to price from1.10x. If the market then recovers from1.05xto1.09x, that recovery is real yield for post-loss suppliers, but the oracle still reports zero growth because the old1.10xhigh has not been exceeded.Recommendation
Do not apply an unbounded high-water clamp to Morpho supply sources. Clamp only decreases that are provably within a small rounding tolerance. Material decreases should either make the oracle invalid until an operator reseeds it or be represented through an explicit epoch/reset mechanism so future yield is measured from the new economic baseline. If the base-rate oracle must remain monotone, the Morpho supply adapter should expose enough information for the wrapper to distinguish harmless rounding drift from real supply-side losses.
-
M-05 Medium Spot-rate index floor erases negative accrual Logical Error Acknowledged
Description
SpotRatemarkets accumulate signed rate-time inRateIndexLib, but settlement does not store or compare the signed accumulator for each swap. Instead,SwapFormulas.indexFromCumulativeRateTime()converts the accumulator into an unsigned index and clamps that index toMIN_INDEX. Once the accumulator has fallen far enough to hit the floor, additional negative accrual produces the same closing index.SwapFormulas.deriveRateFromIndex()later receives only the entry index and the closing index. ForSpotRate, it derives the raw index-ratio rate and then scales it byentryFloatingIndex / INITIAL_INDEX. If a swap is opened shortly before the global spot index reaches the floor, the closing index can no longer represent how far below the floor the signed accumulator moved during the swap. The entry guard inSwapCore.buySwap()only rejects new positions when the current index is already at or belowMIN_INDEX. It does not protect positions opened while the index is still slightly above the floor. For example, a position can enter when the index is about1%ofINITIAL_INDEX, then close after enough negative rate accrual to push the signed accumulator past the floor. The closing index is fixed atMIN_INDEX, so the derived floating rate only reflects the remaining visible index decline instead of the full signed rate-time change. Consequently, existing swaps can understate negative floating-rate accrual during settlement, early exit and liquidation checks. In aBUY_FIXEDmarket, this can reduce the buyer's fixed-minus-floating obligation, return collateral that should have been paid to LPs and delay or prevent liquidation even when the buyer's true obligation is larger than the protocol records.Recommendation
For spot-rate conventions, preserve the signed cumulative rate-time value that backs the index. Store each swap's entry accumulator as well as its entry index, then derive the realized spot rate from the difference between the signed closing accumulator and signed entry accumulator. Historical expiry pricing should resolve the signed accumulator at expiry, not only the floored unsigned index. If interpolation is needed, interpolate the signed accumulator snapshots. Keeping the entry floor check is still useful as a temporary guard, but it is not a complete fix because it cannot protect positions that were opened before the floor was reached.
-
M-06 Medium Vest-Capped Adapter NAV Dilutes Vault LPs Logical Error Resolved
Description
KairosMarketAdapter.realAssets() reports the adapter’s LP position at the profit-vest-capped withdrawal value instead of the uncapped mark-to-market value. The LP profit vest cap is meant to limit what a Kairos LP can realize through an immediate withdrawal while its vest window is active. SwapCore.withdrawCollateral() applies the cap by flooring the burn price to entryPrice when the current share price is higher. The recent LP profit vest fix mirrored that same cap into KairosMarketAdapter._realAssets(), so the adapter reports shares * entryPrice while the position is cap-active, even though the adapter still owns LP shares whose full value can be realized after vest expiry. That capped value is unsafe as parent-vault NAV. A Morpho vault or any vault accounting layer that prices shares from adapter.realAssets() will understate total assets whenever the adapter has unvested LP profit. New depositors can enter against the temporarily capped NAV, mint too many vault shares, and later capture part of the pre-existing LP profit once the vest expires or the adapter deallocates. Existing vault shareholders are diluted even though the profit was generated before the new depositor entered. For example, Alice deposits into a vault and the vault allocates to Kairos through the adapter. The Kairos LP position becomes profitable while the adapter is still inside its vest window, so the uncapped LP share value is above entryPrice. adapter.realAssets() nevertheless reports the lower capped value. Bob then deposits into the vault and receives shares priced against the capped NAV. After vest expiry, the adapter’s full LP value becomes realizable, and Bob’s vault shares are worth more than his deposit, paid out of Alice’s pre-existing hidden profit.
Recommendation
Separate adapter fair-value NAV from immediately withdrawable value. Parent vault share pricing should use uncapped LP MTM, while deallocation and withdrawal paths can continue using the vest-capped realizable value. If the vault cannot distinguish those values, block deposits/mints while the adapter has cap-active hidden profit or add vault-level profit vesting so newly minted vault shares do not participate in pre-existing unvested LP gains.
-
M-07 Medium Source rollback quotes frozen index Logical Error Resolved
Description
IndexBaseRateV1does not apply the same high-water rule in its read logic that it applies when observations are written.poke()clamps any source-index decrease to the latest stored head._realizedTwar()only clamps the live source index up to the left anchor or the interpolated historical index. It never checks the live source index against the newest stored head. This creates a mismatch when the cumulative source has previously reached a high value and later rolls back. The settlement-side cumulative index can remain frozen at the prior high-water mark, whileIndexBaseRateV1can still quote a positive base rate from a lower live value as long as that value is above the older historical anchor. Consequently, a market that uses the same rollback-prone cumulative source for settlement and base-rate pricing can accept aBUY_FLOATINGswap with a positive fixed leg while the floating settlement index remains clamped at the prior high-water mark. The buyer can receive the fixed leg while paying little or no floating leg until the source recovers above the old high. This is most relevant for sources that are only approximately monotonic or whose share ratio can materially decrease.Recommendation
Make
IndexBaseRateV1reject material source rollbacks instead of treating every decrease as harmless rounding drift. The read logic should compare the live source index against the newest stored head and return invalid when the rollback exceeds an explicit, economically negligible tolerance. If small rounding decreases must remain supported, keep the clamp only inside that configured tolerance. Larger decreases should require reseeding, an explicit epoch reset or a source-specific adapter that can distinguish harmless rounding from a real economic reset. -
M-08 Medium Morpho cadence skews spot accrual Oracle Acknowledged
Description
ReferenceRateMorphoreads Morpho'sIIrm.borrowRateView()and returns the result as a normalIRateOraclespot rate. That is unsafe for Morpho Adaptive Curve markets. In that IRM,borrowRateView()returns the average borrow rate over the interval sincemarket.lastUpdate, not the instantaneous borrow rate at the current block. Kairo handlesSpotRateandSpotCompoundRatereference oracles by multiplying the currentgetRate()value by only the time elapsed since Kairo's own previous index update. Anyone can callSwapCore.updateMarketRateIndex(), which means the same Morpho market state can produce different Kairo floating indexes depending on how often the Kairo index is refreshed. Repeated updates apply overlapping Morpho prefix averages to successive Kairo subintervals instead of integrating disjoint rate intervals. Consequently, settlement becomes update-frequency dependent whenReferenceRateMorphois used with a spot-style rate convention for a Morpho market whose IRM changes with elapsed time. If the adaptive borrow rate is rising, frequent Kairo updates understate floating accrual compared with one update at expiry. ABUY_FLOATINGtrader can reduce the floating leg they pay while still receiving the fixed leg. If the adaptive rate is falling, the distortion reverses and can favorBUY_FIXEDpositions. This requires the referenced Morpho market to use an elapsed-time-dependent IRM, such as Adaptive Curve and to remain unaccrued in Morpho for a meaningful interval. It does not require oracle signer control, governance access or privileged Kairo permissions.Recommendation
Do not use
ReferenceRateMorphoas aSpotRateorSpotCompoundRatereference oracle for Morpho Adaptive Curve markets. PreferCumulativeIndexBorrowMorphowithRateConvention.Cumulativeor implement a Morpho adapter that exposes a projected cumulative borrow index instead of a prefix-average borrow rate. If a spot-rate Morpho adapter remains available, restrict it to IRMs whose read value is a true current spot rate and reject incompatible oracle/convention combinations during market creation. -
M-09 Medium Dust force-dealloc burns valuable LP shares Rounding Reported
Description
Dust force-dealloc burns valuable LP shares
Recommendation
Do not let permissionless
forceDeallocate()realize a large gap between the value of adapter LP shares burned and theassetsreturned to the vault. For partialforceDeallocate()withdrawals, compute the shares thatSwapCore.withdrawCollateral()would burn at the current share price. Reject the call unless the fair value of those shares is close to the requestedassetsamount, allowing only a small rounding tolerance. A stricter fix is to block partialforceDeallocate()withdrawals forKairosMarketAdapterand require allocator-controlled deallocation. Do not rely on the caller-providedmaxSharesToRedeemfield for this protection. A caller can choose a cap that still permits the small rounded share burn while preserving the value transfer. DescriptionKairosMarketAdapter.deallocate()allows permissionlessforceDeallocate()partial withdrawals whenever_capBinding()is false. In that case the permissionless caller chooses theassetsamount and the adapter forwards that exact amount toSwapCore.withdrawCollateral().SwapCore.withdrawCollateral()handles exact-amount partial withdrawals by burningceil(assets * WAD / sharePrice)LP shares and returning exactlyassets. This rounding is normally harmless when the caller owns the LP position, because the caller bears the cost of burning any rounded-up share. Here the caller does not own the adapter's LP shares. The adapter belongs to the vault, whileforceDeallocate()is permissionless. If the market share price is high, a caller can request a dust withdrawal such asassets = 1. The vault receives only one atomic token unit, but the rounded share calculation can still burn one adapter LP share unit. That LP share can be worth far more than the dust returned to the vault. The burned value is not paid to the vault or the force-deallocation caller. It remains in the Kairos pool, raising the value of the remaining LP shares. This creates an incentive for a normal user who also holds LP shares in the same market. They can callforceDeallocate()with a dustassetsvalue, make the vault-owned adapter burn an economically valuable LP share and receive the benefit through their separate LP position as the remaining pool shares become more valuable. The existingforceDeallocate()penalty does not fully protect the vault because it is based on the caller-providedassetsamount. For a dust withdrawal, the penalty is also dust-sized even when the adapter's LP-share loss is much larger. The 128-byte withdrawal payload makes this easier because it leavesmaxSharesToRedeemdisabled. The 160-byte payload is not a complete defense either, because an attacker can setmaxSharesToRedeemto the small rounded share burn that creates the value transfer. Because of this, a permissionless caller can burn vault-owned LP share value while returning only a dust amount of assets to the vault. The lost value is redistributed to the remaining LPs in the Kairos market. A caller who is also a co-LP can therefore move value from the vault adapter to their own LP position. The issue is limited to states where the adapter's_capBinding()guard is not active, such as after the vest window expires or when the adapter entered a market that was already high priced. The loss per call is bounded by the value of the rounded-up LP share burn, but repeated dust withdrawals can keep burning adapter shares while the vault is compensated only by dust-sized transfers and dust-sized force-deallocation penalties. Recommendation Do not let permissionlessforceDeallocate()realize a large gap between the value of adapter LP shares burned and theassetsreturned to the vault. For partialforceDeallocate()withdrawals, compute the shares thatSwapCore.withdrawCollateral()would burn at the current share price. Reject the call unless the fair value of those shares is close to the requestedassetsamount, allowing only a small rounding tolerance. A stricter fix is to block partialforceDeallocate()withdrawals forKairosMarketAdapterand require allocator-controlled deallocation. Do not rely on the caller-providedmaxSharesToRedeemfield for this protection. A caller can choose a cap that still permits the small rounded share burn while preserving the value transfer. -
M-10 Medium Base cumulative oracles still use stale adapters Configuration Reported
Description
The current Solidity sources for
CumulativeIndexBorrowCompound,CumulativeIndexBorrowMorphoandCumulativeIndexSupplyMorphofail closed when a stale upstream market cannot be projected. On projection-read failure, the current implementations return(0, false)instead of continuing from an old borrow index or Morpho share ratio. The checked-in Base deployment artifacts and the active Base oracle routes still use older adapter runtimes that fall back to stale values during that projection-failure path:CumulativeIndexBorrowCompound_compound_v3_usdc_borrowreturns the previousbaseBorrowIndexfrom acatchblock whengetUtilization()orgetBorrowRate()fails.CumulativeIndexBorrowMorpho_morpho_blue_usdc_cbbtc_borrowfalls back to the stale borrow share conversion whenidToMarketParams()orborrowRateView()fails.CumulativeIndexSupplyMorpho_morpho_blue_usdc_cbbtc_supplyfalls back to the stale supply share conversion whenidToMarketParams()orborrowRateView()fails. The affected live Base routes are:- Compound borrow:
UpgradableTenorRateOracle_compound_v3_usdc_borrowat0x848d1e76923ECd272406B52c3624173f6F346c83points toIndexBaseRateV1at0x3e94b445F735fbd54713446B78AD520797764Aa5, whosesource()is0x70c7B077D5FABb647b37Da0a3C1F9a626b125F44. - Morpho borrow:
UpgradableTenorRateOracle_morpho_blue_usdc_cbbtc_borrow -> IndexBaseRateV1_morpho_blue_usdc_cbbtc_borrow -> 0x5decDf756E7afD1DC4cfC2B230db27555638CddF. - Morpho supply:
UpgradableTenorRateOracle_morpho_blue_usdc_cbbtc_supply -> IndexBaseRateV1_morpho_blue_usdc_cbbtc_supply -> 0xEca849ed340606512D5D168aa39CF863FC7F6Bd7. Consequently, markets depending on those Base base-rate stacks can quote from stale Compound or Morpho cumulative data during exactly the projection-failure condition the current local source rejects.
Recommendation
Redeploy the affected cumulative-index adapters from the current source and deploy new
IndexBaseRateV1instances seeded against those fixed sources. Then repoint the correspondingUpgradableTenorRateOraclewrappers through the timelocked downstream update flow. Before activation, verify each live runtime is not a stale-fallback artifact by normalizing immutable constructor arguments and comparing bytecode against the current build. Update or retire stale deployment artifacts so operators do not treat the old adapters as production-safe implementations. -
M-11 Medium Aave index jumps inflate BUY_FLOATING profit Unexpected Behavior Reported
Description
CumulativeIndexSupplyAaveexposes Aave V3getReserveNormalizedIncome(asset)directly as a cumulative source.IndexBaseRateV1then computes the base rate from the live source index divided by the historical TWAR anchor and annualizes that ratio overtwarWindowSec. A one-time increase in Aave normalized income is therefore treated as a trailing-window rate and can be immediately quoted as the fixed/base leg. ForBUY_FLOATING,SwapCore.buySwap()reads this base quote before opening the position, sizes the allowed notional from the same quoted rate and then stores both the quotedbaseRateand the current reference index asentryFloatingIndex. If the same Aave supply index jump has already been captured by the reference index at entry, the future floating leg excludes that jump, while the fixed leg keeps paying the annualized quote for the full swap term. This creates a timing profit vector for a normal user when the external cost of creating a small Aave supply-index jump is lower than the Kairos LP backing that can be claimed. With a 12-hour TWAR window, even a tiny relative source jump is annualized. BecausecalculateAvailableLiquidity()lets notional scale inversely with the same quoted rate, the maximum trade can still lock most of the pool's backing at very low source-jump costs. A local test modeled an Aave supply-index income jump of roughly2 * MIN_RATEannualized over a 12-hour TWAR, then bought a 30-dayBUY_FLOATINGswap immediately after the jump. The entry stored the jumped base quote and the post-jump floating index. With a 177,000,000 USDC source-supply proxy, the modeled source-index cost is48.493150USDC, while the buyer received98,999.999999USDC above posted swap collateral from a 100,000 USDC LP pool. This is related to priorI-14("Missing source-market caps for rate oracles"), but the concrete mechanism is narrower and more severe than the carry-cost utilization examples in that report: a discontinuous Aave supply normalized-income jump can be converted into a full-term fixed-leg payment while the floating leg starts after the same jump. Therefore, if a listed Aave supply-index market has enough Kairos LP backing relative to the cost of moving the source normalized income, a normal user can buy immediately after the source-index jump and extract LP collateral at settlement. The blast radius is bounded by the affected market's locked LP backing, but the trade can be net profitable even when the source-index move is very small. The attack requires a market where:CumulativeIndexSupplyAaveor an equivalent Aave supply normalized-income source backs both the base-rate TWAR and the cumulative reference path.- The user can create or coincide with a one-time Aave supply-index income jump, such as a reserve income/premium event, at a cost below the Kairos LP backing available to the trade.
- New swaps are open immediately after the source jump and before governance or keepers pause the market. Likelihood depends on source-market depth, Aave reserve mechanics, configured TWAR window, swap term, leverage multiplier, fees and Kairos pool size. It is not a privileged-only scenario. Example:
- The attacker identifies a
BUY_FLOATINGmarket whose base oracle is anIndexBaseRateV1over the Aave supply cumulative index and whose reference oracle uses the same post-jump cumulative source. - The attacker causes or times a one-time increase in Aave normalized income.
- Immediately afterward, the attacker buys as much
BUY_FLOATINGnotional asSwapCoreallows. buySwap()stores the annualized jumped base quote asswap.baseRateand stores the already-jumped cumulative index asentryFloatingIndex.- If the source index is flat or grows only normally afterward, settlement excludes the initial jump from the floating leg but pays the stored fixed leg for the entire term.
- The buyer receives posted collateral plus LP backing. When LP backing exceeds the external source-jump cost, the sequence is free profit net of gas and capital costs.
Recommendation
Add source-market risk caps for every external cumulative source used by base-rate or reference-rate markets. The cap should bound maximum Kairos open notional or locked LP backing against conservative source-market manipulation cost for the configured TWAR, term, leverage and fee settings. New swaps should pause or fail closed when the source supply, debt, shares or modeled manipulation cost falls below the required threshold. For Aave supply-index base-rate markets specifically, also prevent discontinuous normalized-income jumps from being immediately tradable as full-term fixed-leg quotes. Options include capping per-window source-index deltas, adding a cooldown after large source-index jumps before
buySwap()can consume the quote, using longer/checkpointed windows that cannot be satisfied by a single live-read jump or separating base-rate quote sources from entry reference-index capture so the same jump cannot be priced into one leg and excluded from the other. -
M-12 Medium Base rate batch quotes can diverge Validation Reported
Description
probeOraclesForMarketCreation()accepts a base-rate oracle whengetRateByTenor(swapTerm)succeeds andgetRates([swapTerm])returns one value. It does not require the two values to match for the same tenor. This lets a market be created with an oracle that reports one base rate to swap entry and closeout logic, but a different base rate to LP NAV and share-price logic.Utils.getBaseRateOracle()readsgetRateByTenor(), whileUtils.getBaseRateOracleBatch()readsgetRates(). Consequently, swaps can be opened with one fixed-rate input while the same positions are marked for LP accounting with another. For example, an oracle can return 5% fromgetRateByTenor(swapTerm)and 50% fromgetRates([swapTerm]). Market creation accepts it because both calls are individually valid. The opened swap stores the 5% base rate, butgetPoolSharePrice()uses the 50% batch quote when calculating unrealized PnL. This can skew LP share price, NAV, deposits, withdrawals and settlement accounting until the oracle is replaced or corrected.Recommendation
When probing the base-rate oracle at market creation, compare the one-element batch quote with the single-tenor quote after applying the same normalization rules used by the live readers. Revert if the values differ. The same invariant should be enforced anywhere a tenor oracle is activated or accepted for market use:
getRates([tenor])[0]must equalgetRateByTenor(tenor)for every required tenor. -
M-13 Medium Partial forceDeallocate can fully exit LP Unexpected Behavior Reported
Description
KairosMarketAdapter.deallocate()rejects permissionlessforceDeallocate()only when the decodedfullAmountflag is true or the profit-vest cap is binding. In the partial case, it forwards caller-controlledassetstoSwapCore.withdrawCollateral()and only checks thatwithdrawn == assets. That does not prove the withdrawal was partial.SwapCore.withdrawCollateral()treats anyamount >= fullValueas a full withdrawal and burns all of the LP's shares. In an idle market where the adapter is the sole LP and the cap is not binding, this can return exactlyassetsbecause the last-LP sweep pays the full pool collateral. The adapter's equality check passes even though the adapter's share balance is zero. Consequently, any caller of Vault V2forceDeallocate()can force the vault adapter out of a Kairos market by settingfullAmount=falseand choosingassetsequal to the adapter's full idle position value. This bypasses the documented rule that full withdrawal is blocked throughforceDeallocate(). It can grief the vault strategy by moving the allocation back to idle vault assets without allocator intent.Recommendation
For
selector == IVaultV2.forceDeallocate.selector, reject any withdrawal that burns the adapter's whole LP position. RecordsharesBeforeandsharesAfter, then revert withFullAmountNotAllowedViaForceDeallocate()whensharesBefore > 0 && sharesAfter == 0. If permissionless partial withdrawals should remain available, also require a meaningfulmaxSharesToRedeemlimit and reject requests whose requestedassetsamount is greater than or equal to the adapter's current realizable LP value. -
M-14 Medium Factory misses non-exit adapter guard Validation Reported
Description
KairosBuyerAdapterFactory.createAdapter()validates that the market exists and that itsswapTokenmatches the vault asset. It does not reject markets whereearlyExitAllowedis false.KairosBuyerAdapteralready documents why that configuration is unsafe for redeemable vault accounting. For live buyer positions on non-early-exit markets,realAssets()is only an accrued fixed-versus-floating mark. It ignores the locked remaining term because the position cannot be closed before maturity. The comment also warns that a redeemable vault using idle cash plusrealAssets()can let early redeemers shift value to or from depositors who remain until expiry. That warning is useful, but it is not a code-level control. A vault authority can still use the official factory to deploy a buyer adapter for a non-early-exit market and then add it to a redeemable vault. Consequently, vault share pricing can consume the accrued-only adapter value even though the code already recognizes that instant redemption is unsafe for this exposure.Recommendation
Enforce the documented restriction in
KairosBuyerAdapterFactory.createAdapter(). After loading the market, reject non-early-exit markets before deploying the adapter.if (!market.earlyExitAllowed) revert NonEarlyExitMarketUnsupported();If non-early-exit buyer exposure must be supported, expose it through a separate hold-to-maturity adapter or vault mode that prevents instant redemption while those swaps are live.
-
M-15 Medium Pre-poke reference indexes skew payouts Unexpected Behavior Reported
Description
liquidateSwap()refreshes the market reference index by callingrateIndex.update(market.referenceRateOracle)directly.withdrawCollateral()does the same atcontracts/SwapCore.sol:374-376. Neither function first callsUtils.pokeMarketOracles(...). This is inconsistent withupdateMarketRateIndex(), which pokes the market's reference, base and risk oracles before it snapshots the reference index.Types.CreateMarketParamsandSwapCore._initializeMarket()document that a market'sreferenceRateOraclemust be poke-invariant, meaning its read must not change acrosspoke(). That precondition is only caller-enforced.validateMarketParams()andprobeOraclesForMarketCreation()can still accept anIPokeablereference oracle whose valid output changes only afterpoke()is called. Consequently, a market that violates the documented reference-oracle precondition can execute value-bearing state changes against stale pre-poke reference data even though fresh data is available through the protocol's own refresh function. InliquidateSwap(), stale data can make a swap liquidatable even though it would be healthy after the reference oracle is poked, causing buyer collateral or pool backing to move under the wrong index. InwithdrawCollateral(), an LP can redeem shares at a stale share price and leave the later post-poke loss with remaining LPs.Recommendation
Enforce the documented reference-oracle precondition at market creation. Reject
referenceRateOracleaddresses that advertiseIPokeableunless they are explicitly allowlisted as read-invariant acrosspoke(). This closesliquidateSwap(),withdrawCollateral()and any future value-bearing function that directly refreshes the reference index. As defense in depth, make every value-bearing direct reference-index refresh consume the same committed oracle state asupdateMarketRateIndex(). BeforerateIndex.update(market.referenceRateOracle)inliquidateSwap()andwithdrawCollateral(), call:Utils.pokeMarketOracles( rateIndex, market.referenceRateOracle, market.baseSwapRateOracle, market.riskPremiumOracle, admin.pokeMinSpacingSec() );Prefer a shared internal helper so future value-bearing index refreshes cannot accidentally bypass the poke step.
-
M-16 Medium Negative tenor quote inflates LP NAV Validation Reported
Description
This issue requires a specific oracle configuration: the market's tenor base-rate oracle must be able to return a negative shorter-tenor quote for a cumulative
BUY_FIXEDmarket while the full-term quote remains non-negative. If deployments enforce non-negative rates for every tenor off-chain, this issue is not reachable in that deployment. The on-chain code only enforces that invariant for the full-term quote read duringbuySwap().SwapCore.buySwap()recognizes that a cumulative reference index is monotone and rejects a negativeBUY_FIXEDbase rate at entry withE512. That check only covers the fullmarket.swapTermquote. Active LP mark-to-market later collects each bucket's remaining tenor incalculateTotalUnrealizedPnL(), reads that shorter tenor throughgetBaseRateOracleBatch()and passes the signed result intocalculateBucketUnrealizedPnL(). That function prices the remaining floating leg withprojectedRatewithout rejecting negative rates for cumulativeBUY_FIXEDmarkets. Consequently, a curve that is positive at the full swap term but negative at a shorter remaining tenor can pass swap entry while later marking active LP buckets as if the monotone floating index could accrue negative future growth. A mature LP can withdraw at the inflated NAV before settlement realizes the actual cumulative-index outcome. The remaining LPs absorb the shortfall. We developed a test that used a 1-year cumulativeBUY_FIXEDmarket with a valid positive 5% full-term base quote and a negative -50% 6-month remaining-tenor quote. After half the term, the attacker LP withdrew1,026,315.789474USDC from a1,000,000USDC deposit. After settlement, the remaining LP's value fell to923,684.210526USDC.Recommendation
Apply the same cumulative
BUY_FIXEDnon-negative-rate invariant to every remaining-tenor base-rate consumer, not only tobuySwap(). Reject negativeprojectedRatevalues in LP mark-to-market whenmarket.rateType == BUY_FIXEDand the reference convention isCumulative. Also reject negative remaining-tenor rates before early-exit settlement. As a defense-in-depth layer, tenor oracles and wrappers used by cumulativeBUY_FIXEDmarkets should reject negative rates for every supported tenor, not only for the default or full-term tenor. -
M-17 Medium RawIndex underprices BUY_FIXED LP backing Configuration Reported
Description
RawIndexBaseRateV1is explicitly documented as a BUY_FLOATING-only base oracle. It returns the raw trailing TWAR raterfor every tenor instead of the full-tenor compounded-equivalent quote. That lower-bound quote is safe when the BUY_FLOATING buyer receives the fixed leg, because any extra cumulative-index growth accrues to the pool. The same quote is unsafe for BUY_FIXED. In BUY_FIXED, the buyer pays the fixed leg and receives the cumulative floating leg. If the market usesRawIndexBaseRateV1as the fixed-side base oracle while settling against the same compounding cumulative source,buySwap()stores only the raw simple rate asswapRate. Settlement later derives the floating payment from the higher cumulative-index growth. The code relies on comments for this side-specific precondition.SwapCore.createMarket()accepts an arbitraryfixedBaseSwapRateOracleand stores it in the BUY_FIXED market, whileUtils.probeOraclesForMarketCreation()only checks thatgetRateByTenor()andgetRates()return valid values. It never checks whether the oracle is safe for the side it is being assigned to. LP backing in an affected BUY_FIXED market can be transferred to buyers at normal expiry. A local test usedCumulativeIndexBorrowAaveadapter as the reference oracle and aRawIndexBaseRateV1sourced from the same Aave borrow index. With a 180-day term, 18x leverage and a 104.5% APR Aave-borrow stress rate, the buyer locked a raw fixed rate of about 104.5% APR, but the Aave-style borrow index realized a materially larger cumulative floating leg. Settlement paid the buyer their posted margin plus the full9,999,999.971370USDC of locked LP backing. The issue requires a market to useRawIndexBaseRateV1in the fixed-side base-oracle slot. That is an unsafe configuration, but it is possible because market creation validates only interface shape and successful reads, not whether the oracle's quote semantics match the market side. If a market creator, integration, listing process or wrapper retargeting puts the first-party RawIndex oracle into a BUY_FIXED market and normal LPs supply that market, positive compounding sources can create a material gap that grows withrate * term, while LP backing is sized from only the raw fixed rate divided by leverage. This is the inverse of the existing bumped-quote BUY_FLOATING basis issue (H-02). That issue coversIndexBaseRateV1overpaying the fixed leg to BUY_FLOATING buyers. This issue coversRawIndexBaseRateV1undercharging BUY_FIXED buyers while the floating leg still compounds.Recommendation
Enforce the side-specific oracle precondition on-chain. The minimal fix is to add an interface or immutable metadata flag for tenor-oracle quote semantics and reject
RawIndexBaseRateV1-style unbumped quotes in the BUY_FIXED base-oracle slot during market creation and wrapper retargeting. More generally, cumulative markets should distinguish at least:- pool-safe upper-bound or compounded-equivalent tenor quotes allowed for BUY_FIXED;
- lower-bound raw/simple tenor quotes allowed only for BUY_FLOATING.
-
L-01 Low LP withdrawals lack max-share slippage Warning Resolved
Description
Partial amount-based LP withdrawals do not let the caller cap the number of LP shares burned. When
amountis below the LP's full position value,withdrawCollateralcomputessharesToRedeem = ceil(amount * WAD / sharePrice)and then setswithdrawalAmount = amount. The only slippage check isminCollateralOut, which compares againstwithdrawalAmount. In this mode,minCollateralOut <= amountalways passes, regardless of how far the execution-time share price moved or how many shares the call now needs to burn. The API has nomaxSharesInormaxSharesToRedeemparameter. If the share price moves down between quote and execution, the LP still receives the requested token amount, but more shares are consumed than the LP or integrator may have intended.KairosMarketAdapter.deallocateexposes the same amount-mode behavior for Morpho vault deallocations: it decodes onlyminCollateralOutandfullAmount, forwards the request toswapCore.withdrawCollateraland checks only that the token amount withdrawn equals the requestedassets.Recommendation
For amount-based withdrawals, add a
maxSharesInormaxSharesToRedeemparameter and revert when the computedsharesToRedeemexceeds it. Adapters should decode and forward this cap so parent vaults can protect share inventory during deallocation. -
L-02 Low Short-tenor liquidity floors to zero Rounding Resolved
Description
SwapFormulas.calculateAvailableLiquidity()first computes:Math.mulDiv(poolCollateral, leverageMultiplier, effectiveRate(baseRate))and only then scales that intermediate result by
SECONDS_IN_YEAR / term. IfpoolCollateral * leverageMultiplieris smaller thaneffectiveRate(baseRate), the first division floors to zero before the short-tenor multiplier can restore the value. A single full-precision calculation over the combined numerator and denominator preserves that capacity:Math.mulDiv(poolCollateral, leverageMultiplier * SECONDS_IN_YEAR, effectiveRate(baseRate) * term)The same floored value is used by
SwapCore.buySwap()for theE508liquidity gate and byImpliedBaseRate.getBaseRate()when it compares paired-market liquidity.Recommendation
Replace the two-step calculation with a single full-precision
mulDivover the combined numerator and denominator, for example:Math.mulDiv(poolCollateral, leverageMultiplier * SECONDS_IN_YEAR, effectiveRate(baseRate) * term)If the combined numerator can overflow before
mulDivreceives it, split the expression into a safe 512-bit decomposition rather than multiplying in uncheckeduint256space. -
L-03 Low Dust collateral blocks full adapter exits DoS Acknowledged
Description
KairosMarketAdapter.deallocate()treatsfullAmount=trueas the adapter's full LP withdrawal mode. In the current implementation, that mode requiresassets == 0and passestype(uint256).maxtoSwapCore.withdrawCollateral(), which triggers a full redemption of the adapter's LP position.SwapCore.withdrawCollateral()computes the full-position value from the LP share balance and the current share price. If active swaps exist, it keeps one share outstanding, but the resulting withdrawal amount is still almost the full LP position. The function then rejects the withdrawal when that amount is greater thantotalLPAvailableCollateral(), which ispool.totalCollateral - pool.lockedCollateral. This preservesSwapCore's locked-collateral invariant, but the adapter's full-exit mode has no way to withdraw the currently available collateral while keeping the residual locked shares and claim. As a result, any active swap with nonzero LP backing, even a dust-sized one, blocks the adapter's full-market exit until the swap settles or is otherwise closed.Recommendation
Change the adapter's full-exit behavior to be lock-aware. It should withdraw the maximum currently available collateral and keep tracking the residual locked shares or claim or expose a separate
withdrawAvailableAndKeepResidualpath. Do not route operational full exits exclusively through an all-share redemption that reverts wheneverlockedCollateralis nonzero. -
L-04 Low PythAdapter rejects valid negative rates Oracle Resolved
Description
KairoSwap's shared rate consumers are built around signed rates.
IRateOracle.getRate()returns anint256,Utils.getOracleRatedocuments that reference rates can be negative,RateIndexLibaccumulates signed spot-rate time andSwapFormulashandles negative rates when deriving and collateralizing swap exposure. Pyth also representsPythStructs.Price.priceas a signed integer.PythAdapter.getRate()rejects that signed domain by returning(0, false)wheneverpythPrice.price <= 0and then rejecting again if the scaled value is non-positive. A Pyth rate feed that legitimately becomes negative is therefore treated as invalid instead of being returned as a signed rate.Recommendation
Permit negative Pyth prices for rate feeds and scale them while preserving sign. Only reject genuinely invalid timestamp/staleness/overflow conditions or return (0,false) for invalid data so callers can handle invalidity consistently rather than reverting in the adapter.
-
L-05 Low Settled swap ID reverts settlement batch Warning Resolved
Description
SwapCore.makePaymentis permissionless and accepts an arbitrary array of swap IDs. It forwards the array to_settleSwaps, which processes entries sequentially. For each ID,_settleSwapsloads the swap and reverts withErrors.E501()whenswap.settledis already true instead of skipping the entry or returning a per-item failure status. This lets any third party poison a keeper or integration settlement batch. The third party can settle one expired swap ID that is included in the pending batch. When the keeper's batch later reaches that pre-settled ID, the whole transaction reverts. If the poisoned ID is near the end of the array, the keeper also spends gas on the earlier settlement work before the final revert rolls those state changes back.Recommendation
Make batch settlement idempotent for already-settled swap IDs. For example, skip settled entries or return a per-item status so one pre-settled ID cannot revert unrelated settlements in the same batch.
-
L-06 Low LP adapter bypasses market whitelist Validation Acknowledged
Description
KairosMarketAdapter.allocate()receives the original vault caller in itssenderparameter but does not use it. The adapter instead supplies collateral withSwapCore.supplyCollateral(marketId, assets, address(this), 0), soSwapCoreevaluates the per-market LP whitelist against the adapter address, not against the vault user or allocator that initiated the allocation. This means that once a market owner whitelists theKairosMarketAdapter, the whitelist admits the adapter's parent vault as the economic LP wrapper. An address that is not permitted to supply LP collateral directly can still gain exposure to the whitelisted market through the vault's allocation and share ownership model, because the only LP address recorded and checked bySwapCoreis the adapter.Recommendation
If whitelisted markets require end-user permissioning, enforce the original
senderagainst a market- or vault-level allowlist before the adapter supplies collateral. Vault shares should also be restricted or non-transferable for those markets if share ownership is meant to represent approved LP exposure. If adapter-level permissioning is intended, document this explicitly for market owners: whitelisting a Kairos LP adapter admits all current and future economic participants of the adapter's parent vault, not only the adapter deployer or allocator. -
L-07 Low Idle LP value query needs live oracle Warning Resolved
Description
getLpValue()is documented as the value an LP would receive on an immediate full withdrawal, but it always callsgetPoolSharePrice()before returning that value.getPoolSharePrice()reads the market reference oracle throughUtils.getFreshIndex()whenever the pool has shares, sogetLpValue()can revert if the oracle is unavailable.withdrawCollateral()does not require the oracle in the same idle-pool case. Whenpool.lockedCollateral == 0, it computes the withdrawal price directly frompool.totalCollateral / pool.totalSharesand bypasses the reference oracle entirely. Therefore, an LP can be able to withdraw from an idle pool whilegetLpValue()reverts. Integrations that rely ongetLpValue()to display, size or account for the LP's realizable position can become unavailable during an oracle outage even though the collateral is unlocked and withdrawable.Recommendation
Move effective LP withdrawal pricing into a shared internal helper used by both
getLpValue()andwithdrawCollateral(). In the idle-pool case, the helper should compute the share price from stored pool totals without calling the reference oracle, then apply the same vesting cap for both functions. If theMIN_SHARE_PRICEredemption floor is removed or changed separately, the shared helper should use that corrected idle-pool pricing rule as well. -
L-08 Low First market can lock spot oracle convention DoS Acknowledged
Description
RateIndexLib.initialize()stores oneRateConventionper oracle address the first time any market uses that oracle. If the oracle has already been initialized, any later market that asks for a different convention reverts withE607.SwapCore.createMarket()is permissionless and forwards the creator-suppliedrateConventioninto this initialization. This is safe forCumulativeoracles because they must implementgetCumulativeIndex(). It is not safe for the two spot conventions, because bothSpotRateandSpotCompoundRatevalidate through the sameIRateOracle.getRate()function and start from the sameINITIAL_INDEX. Therefore, the first market creator can lock an unused spot oracle address to the wrong spot convention. For example, if an oracle is meant to be used asSpotCompoundRate, another creator can initialize it first asSpotRate. The intended market then reverts and every future market that tries to use the same oracle address withSpotCompoundRatealso reverts. Consequently, this creates a low-cost market-creation griefing issue for newly deployed spot oracle addresses. The attacker does not need to corrupt oracle data or interact with existing funds. Recovery requires deploying a replacement oracle or wrapper address, then using that new address for the intended markets.Recommendation
Do not let the first market creator define the convention for a shared oracle address. Make the convention intrinsic to the oracle through immutable metadata and verify it during
initialize(). Alternatively, key the rate-index state by(oracle, convention)soSpotRateandSpotCompoundRatecannot block each other. If neither design is acceptable, restrict first-time oracle initialization to an authorized registry or market-creation role that has already approved the oracle and its convention. -
L-09 Low Long-tenor base-rate compounding can revert Unexpected Behavior Resolved
Description
IndexBaseRateV1.getRateByTenor()andIndexBaseRateV1.getRates()first compute a valid realized TWAR rate, then pass that rate and the requested tenor into_applyTenorBump(). For positive rates,_applyTenorBump()computesrWad * tenorSeconds / SECONDS_IN_YEARand passes the result directly toFixedPointMathLib.expWad(). There is no explicit bound on the rate-tenor product before the exponential call. A realized rate can be valid for ordinary tenors but too large to exponentiate for a very long tenor. For example, a 200% realized APR combined with a 100-year tenor exceeds the supportedexpWad()input range even though both the rate and the tenor are otherwise representable. Consequently, a base-rate read that should be reported as invalid can hard-revert instead. Markets that depend on the oracle for that tenor can then reject market creation, new swaps, early exits and LP valuation-dependent supply or withdrawal operations until either the rate decreases or the queried remaining tenor becomes small enough.Recommendation
Validate the complete rate-tenor computation before calling
expWad(). IfrWad * tenorSeconds / SECONDS_IN_YEARis outside the supported exponential range, return(0, false)fromgetRateByTenor()and(rates, false)fromgetRates()instead of reverting. Also guard the multiplication afterexpWad(), since an exponent just inside the input range can still produce an output too large for the final arithmetic. If long-tenor compounding is intentionally unsupported above a threshold, enforce that threshold during market creation or document it as an oracle-level bound. -
L-10 Low Pool-zero event misses exact drains Events Resolved
Description
PoolCollateralZeroedis only emitted after settlement when_applyPoolValueChanges()returnstrue. That helper returnstrueonly when the requested decrease is greater thanpool.totalCollateral. If the decrease exactly equals the remaining pool collateral, the helper subtracts the amount, leavespool.totalCollateralat zero and returnsfalse. This exact drain is a normal reachable case because settlement losses are capped by the swap'spoolCollateralBacking. A swap can consume the final remaining pool backing without the helper reporting that the pool moved from positive collateral to zero. Pool-side liquidation has the same reporting gap.Utils.liquidateSwap()calls_applyPoolValueChanges(pool, 0, poolBacking)when the pool is liquidated, but the return value is ignored.SwapCore.liquidateSwap()also has no independent check that the pool moved to zero before emitting close events. Consequently, event-driven monitors can miss the moment a market becomes a zero-collateral pool while LP shares may still exist. Indexers, routing systems and automated LP controls that rely onPoolCollateralZeroedcan continue treating the pool as not-yet-wiped unless they recompute pool state from storage or every balance-changing event.Recommendation
Make
_applyPoolValueChanges()report positive-to-zero transitions, not only strict underflow clamps. Capture the pool collateral before applying the change and returnoldCollateral > 0 && pool.totalCollateral == 0. Propagate that result through both settlement and liquidation.SwapCoreshould emitPoolCollateralZeroedexactly once whenever a market's pool collateral moves from a positive value to zero, including exact drains and pool-side liquidations. -
L-11 Low SwapClosed reports accrued unsigned legs Events Resolved
Description
SwapClosedexposesfloatingPaymentandfixedPaymentasuint256fields.Utils.settleSwap()fills those fields by taking the absolute value of the signed fixed and floating payments. This removes the sign of each leg, so the event cannot describe settlements where supported negative rates make one leg negative. For early exits, the event fields also do not describe the closeout that determined the transfer.calculateEarlyExitSettlement()computes the real obligation from accrued payments, projected remaining payments, the cumulative cross-term where needed and the early-exit fee. After that,settleSwap()recomputes only the elapsed accrued fixed and floating payments for event reporting. The remaining-term value that can driveamountSettledis not included infixedPaymentorfloatingPayment. Consequently, event-only accounting cannot reliably reconstruct realized PnL or reconcile the reported legs toamountSettled. Integrations that subtract the two unsigned magnitudes can get the wrong sign when one leg is negative and integrations that use the fields for early exits can miss the projected closeout component entirely. Liquidation events are even less informative:SwapCore.liquidateSwap()emits the unifiedSwapClosedevent with both leg fields set to zero even though the liquidation decision used an accrued net obligation.Recommendation
Version the close event instead of reusing unsigned accrued-leg fields for multiple closure types. Emit signed fixed and floating components for normal settlement. For early exits, also emit the signed projected remaining components and the retained fee or emit a signed pre-fee closeout amount plus the retained fee. For liquidations, emit the signed accrued obligation that made the position liquidatable. At minimum, rename or deprecate the current leg fields so consumers do not treat them as a complete realized closeout decomposition.
-
L-12 Low Sweep leaves stale tracked swap Unexpected Behavior Resolved
Description
ACTION_SWEEPtransfers the buyer adapter's entire token balance to the parent vault. It then falls through to the common allocation update, but it does not inspect_swapIdsor remove any settled swap whose payout was already paid into the adapter. This matters when a tracked buyer swap is settled outsideACTION_MAKE_PAYMENT, so the payout is sitting in the adapter's token balance. A later sweep can move that payout to the vault and reduce the adapter's reported allocation to zero. The settled swap can still remain in_swapIdsandtotalTrackedSwapsstill counts it. For example, a vault curator configures a buyer adapter with a smallmaxTrackedSwapslimit and uses every slot for a rolling rate strategy. One swap reaches maturity and is settled directly throughSwapCore, so the payout lands on the adapter as idle token balance while the swap id remains tracked. The allocator sees the idle balance and callsACTION_SWEEPto recover it to the vault. The sweep succeeds, but the adapter reports zero remaining allocation and does not remove the settled id. The next attempt to roll into a replacement swap then fails withSwapCapExceeded, while ordinary settled-swap cleanup can also be blocked because the vault now records zero allocation for the adapter id. The direct asset impact is limited because the swept payout reaches the parent vault. Existing vault depositors are not deprived of that cash. The practical impact is instead a persistent strategy outage: the vault can no longer roll the adapter into a replacement buyer swap while the stale id consumes the final tracking slot. Depositors remain exposed to a vault whose advertised Kairos buyer allocation may be inactive and the vault can miss intended rate exposure, hedge protection or yield until an operator restores allocation or replaces the adapter. Consequently, an adapter that was already atmaxTrackedSwapscan be left unable to buy another swap becauseallocate()rejects new buys whentotalTrackedSwaps >= maxTrackedSwaps. OrdinaryACTION_REMOVE_SETTLEDcleanup can also become unavailable after the sweep, since the adapter documents thatVaultV2rejects deallocation calls when the recorded allocation is zero. The stale slot can therefore persist even though the underlying swap is already settled and the cash has been recovered. Operators may need governance or curator intervention to restore nonzero allocation, deploy a replacement adapter or otherwise repair the stale tracking state.Recommendation
Do not let
ACTION_SWEEPcreate a zero-allocation state while fully settled swaps remain tracked. Reconcile settled buyer swaps before or during the sweep and call_removeSwapId()for any tracked swap that is settled, has no pending escrow and no longer has a settlement payout to account for. Alternatively, makeACTION_SWEEPreject while settled buyer swaps are still tracked and require the operator to use the settlement-specific actions first. This keeps token recovery and swap tracking consistent and prevents a sweep from consuming the adapter's future swap capacity. -
L-13 Low Delayed relays bypass max-rate-change breaker Oracle Resolved
Description
BaseTimelockedOracle._validateRateVector()decides whether to enforcemaxRateChangeWadfrom relay time, not from the signer-attested observation time. The previous rate timestamp is now the storedobservedAt, but the freshness check still usesblock.timestamp - prevUpdatedAt <= maxAgeSec. This lets a relayer control whether the rate-change breaker applies. If a signed update would exceedmaxRateChangeWadwhen posted immediately, the relayer can wait until the previous observation is older thanmaxAgeSecwhile the new signed observation is still fresh. The same update then passes because_validateRateVector()treats the previous value as stale and skips the delta check. Consequently, a valid signed rate jump larger than the configured circuit-breaker limit can be installed without signer compromise. This can abruptly reprice fixed-rate forecasts or sided risk premiums. Markets that consume those oracle values can misprice newly opened swaps, early exits, LP mark-to-market values and risk-premium calculations until the next accepted update corrects the curve. For example, assume a 5% rate was stored with observation timeT0. Signers later attest a 20% rate atT1, where the 15% jump exceedsmaxRateChangeWad. Posting that update atT1reverts withRateChangeTooLarge. If the relayer waits untilblock.timestamp > T0 + maxAgeSecbut still withinT1 + maxAgeSecand before the signed deadline, the same signed update is accepted.Recommendation
Validate rate-change cadence against observation time, not relay time. Pass the incoming
observedAtinto_validateRateVector()and enforcemaxRateChangeWadwhenobservedAt - prevUpdatedAt <= maxAgeSec. Also require observation timestamps to move forward for each stored tenor. The delta check should only be skipped after a genuine observation gap larger thanmaxAgeSecor after an explicit governance reset that intentionally clears the prior rate state. -
L-14 Low Duplicate tenor probe accepts bad batches Validation Resolved
Description
UpgradableTenorRateOracle._validateDownstreamHealth()validates a replacement downstream by readinggetRateByTenor(defaultTenorSeconds)and then callinggetRates()with the same default tenor twice. The check only requiresisValid = trueand a response array with the same length as the probe. That proves the downstream can answer one repeated tenor. It does not prove thatgetRates()returns each rate at the same position as the requested tenor when the input contains distinct tenors. It also does not compare the batch result against the correspondinggetRateByTenor()value. A downstream can therefore pass activation while returning correct single-tenor reads and wrong batch results for the heterogeneous tenor arrays used by LP mark-to-market accounting. Consequently, active LP share pricing can use rates attached to the wrong bucket. If the resulting share price is understated, new LPs can deposit too cheaply and dilute existing LPs. If it is overstated, withdrawing LPs can exit against inflated NAV before the remaining LPs absorb the correction.Recommendation
Probe at least two distinct tenors during downstream activation. For every probed tenor, require the batch value at that index to match the corresponding
getRateByTenor()result, in addition to checkingisValidand array length. If one wrapper can serve markets with several terms, keep an explicit required-tenor set or required tenor range and validate those terms before accepting a new downstream. -
L-15 Low Default tenor probe misses live tenors Validation Resolved
Description
UpgradableTenorRateOracle._validateDownstreamHealth()only checks the configureddefaultTenorSeconds. The batch probe repeats that same tenor, so a candidate downstream can pass activation even if it does not support other market terms or remaining-tenor values already used through the wrapper. This can happen with a non-malicious oracle migration. For example, a downstream may truthfully support the wrapper's 30-day default tenor while returning invalid for 90-day or 180-day markets.BaseUpgradableOracleWrapper.activateDownstream()still accepts it because the health check never asks for those live tenors. Consequently, existing markets that rely on unsupported tenors can lose oracle liveness after a successful downstream activation. New swaps, early exits, LP share pricing and adapter valuation can revert or return invalid until another downstream is queued and the three-day timelock elapses.Recommendation
Do not use
defaultTenorSecondsas the only compatibility check for an upgradeable tenor oracle. Bind each wrapper to the market terms it is expected to serve, then validate every required tenor before activation. At minimum, include a heterogeneous batch probe and compare each batch result with the corresponding single-tenor read. If the candidate returns invalid or reverts for any required tenor, reject the downstream change. -
L-16 Low Tenor removal reuses old premium anchors Oracle Resolved
Description
activateTenorBucketsChange()replaces the activetenorBucketsarray and resetslastPostTimestamp, but it does not clear stored rates for tenor keys that remain in the new bucket set. Those retained rates were signed for the old tenor configuration. After an intermediate bucket is removed,_getRatesInternalWith()can still load the surviving anchors, interpolate across the new bucket layout and returnisValid = true. Consequently, a bucket removal can immediately change the premium returned for a wrapper's immutable tenor even though signers never attested to that new curve. For example, a signed[30d, 60d, 90d]curve with premiums[1%, 10%, 1%]can become a valid[30d, 90d]curve after activation. A 60-day wrapper then interpolates1%from the retained endpoints instead of becoming invalid until signers post a curve for the new bucket set. New swaps opened during that window snapshot the unsigned premium into their pricing.Recommendation
Clear stored rates whenever a tenor bucket change is activated. After assigning the new
tenorBuckets, call_resetStoredRates()and keeplastPostTimestamp = 0so signers can immediately post a fresh curve for the new bucket list. Reads should remain invalid until that post succeeds. -
L-17 Low No-op wrapper poke carries cooldown Oracle Resolved
Description
OraclePokeLib.poke()recordslastPokes[oracle] = block.timestampbefore it callsoracle.poke(). It only rolls that timestamp back when the low-level call fails. This interacts badly withBaseUpgradableOracleWrapper.poke(), which returns successfully without touching any downstream oracle when the active downstream does not advertiseIPokeable. As a result,SwapCore.updateMarketRateIndex()can record a successful poke against the stable wrapper address even though the wrapper did nothing. If governance then activates a pokeable downstream before the protocol-level poke spacing expires, automatic pokes throughupdateMarketRateIndex()are skipped until the old wrapper-level cooldown has elapsed. Consequently, a newly activated downstream can become stale or invalid before its first eligible automatic refresh. Rate-dependent operations can temporarily revert or use a near-stale quote. The issue is bounded because a direct call to the wrapper'spoke()still reaches the new downstream, but permissionless automatic refreshes can be suppressed during the inherited cooldown window.Recommendation
Do not let a successful wrapper no-op consume the protocol-level poke cooldown for a future downstream. One option is to make wrapper
poke()expose whether it actually forwarded to the downstream and only updatelastPokesafter a real downstream poke. Alternatively, key poke cooldown state by a wrapper downstream generation rather than only by the stable wrapper address. When activating a downstream that advertisesIPokeable, consider poking it before the final health check and validating the post-poke read. -
L-18 Low Negative premiums brick swap entry Oracle Resolved
Description
postRiskPremiums()validates upper and lower premium vectors only against the signed rate bounds inherited fromBaseTimelockedOracle. If those bounds include negative values, authorized signers can post a negative risk premium and the side-specific wrapper can report it as(rate, true). Wrapper health checks and market creation only check that the oracle value is valid.Utils.getRiskPremiumOracleRate()later applies a stricter consumer rule and reverts withE612when the returned rate is negative. Consequently, a market can be created or a wrapper upgrade can be activated while the current premium is already unusable by swap entry. Every new swap on the affected side then reverts until a nonnegative premium is posted.Recommendation
Use one sign policy across the risk premium producer, wrappers and
SwapCoreconsumer. If risk premiums are meant to be unsigned, require nonnegative minimum bounds and reject negativeupRatesandloRatesinpostRiskPremiums(). If signed premium adjustments are intended, updateSwapCorepricing, collateral and markup calculations to consume signed values without reverting. -
L-19 Low Virtual NAV skips settlement pokes Unexpected Behavior Acknowledged
Description
VirtualShareLib.computeVirtualSharePricecomputes the virtual LP share price from the current oracle state without executing the market oracles'poke()hooks. It reads the current reference index throughUtils.getFreshIndexand then uses that value to model expiry settlement and remaining exposure. Actual settlement does not use the same oracle state.SwapCore.updateMarketRateIndexcallsUtils.pokeMarketOraclesbefore updating the market index and expired-swap settlement calls that function before resolving the expiry index. A healthyIPokeableoracle can return valid data before the poke and different valid data after the poke. Therefore,getPoolSharePriceVirtualcan report a share price that differs from the share price immediately after the same expired swaps are actually settled.KairosMarketAdapter._realAssetsconverts this virtual share price into vault-facing assets, so a parent vault can price deposits or withdrawals against a pre-poke NAV and then crystallize a different value once settlement is triggered. The issue requires a pokeable market oracle whose valid output changes onpoke()and it is most relevant when active exposure remains after the expired swaps are removed.Recommendation
Make virtual pricing and actual settlement consume the same committed oracle state. One safe approach is to require oracle pokes to be committed in a separate synchronization transaction before either
getPoolSharePriceVirtualor settlement uses the data. Another option is to provide a standardized view-only post-poke preview and use it consistently for both virtual share pricing and settlement. Settlement should not implicitly change oracle inputs after a virtual NAV has already been quoted from pre-poke data. -
L-20 Low Forced Bounds Reset Keeps Old Posting Cooldown Oracle Resolved
Description
Forced rate-bounds activation can leave the oracle invalid while blocking the recovery post that is supposed to restore it. activateRateBoundsChange(true) skips the strict in-bounds check and calls _resetStoredRates(), causing all reads to return isValid=false until signers post a fresh in-bounds curve. The function documentation explicitly describes recovery as a single immediate post. However, the forced path does not reset lastPostTimestamp. If keepers post under the old bounds shortly before governance activates the forced bounds change, _validateTenorsAndCadence() still measures the next post against that old timestamp and reverts with UpdateTooFrequent. The stored rates have already been cleared, so the oracle remains invalid until minUpdateIntervalSec elapses. With the deployed-style 4 hour cadence, a forced emergency activation can therefore create a deterministic multi-hour oracle outage. This affects both FixedRatePricingOracle and RiskRateOracle, because both inherit the shared cadence gate and both clear their stored rate state through _resetStoredRates(). During the outage, dependent market creation, swap entry, early-exit pricing, LP MTM paths, and risk-premium reads can fail because the oracle reports invalid data.
Recommendation
Reset lastPostTimestamp inside the forced activation branch after _resetStoredRates(), so the first recovery post can land immediately and the cadence re-arms on that successful post.
-
L-21 Low Over-aged seed starts oracle invalid Validation Resolved
Description
IndexBaseRateV1requires a constructor seed so the oracle can serve rates immediately after deployment. The constructor only checks that_seedTimestampis not newer thanblock.timestamp - twarWindowSec. It does not check that the seed is close enough to that initial TWAR boundary to satisfy the oracle's own freshness rule. If the seed is older than the allowedmaxLiveGapSecwindow, deployment succeeds but the first rate read is already invalid._realizedTwar()selects the old seed as the left anchor and then rejects it becausetargetTime - left.timestamp > maxLiveGapSec. Therefore, an operator can deploy an oracle that looks successfully initialized but cannot pass market-creation probing or serve rates. Ordinary pokes cannot immediately repair the state because new observations are initially to the right of the TWAR boundary. The oracle remains unusable until enough time passes for fresh observations to bracket the boundary.Recommendation
Validate the seed against the first TWAR boundary during construction. The constructor should reject any seed where:
block.timestamp - twarWindowSec - _seedTimestamp > maxLiveGapSecEquivalently, require the seed timestamp to match the intended boundary within a small explicit tolerance. Every accepted deployment should return a valid rate immediately when the source itself is valid.
-
L-22 Low Tenor shortening can brick live markets Unexpected Behavior Partially resolved
Description
BaseTimelockedOraclelets governance replace the activetenorBucketsset without checking whether existing markets still need longer tenors.activateTenorBucketsChange()assigns the pending bucket list directly and resetslastPostTimestamp, but it does not know whichswapTermvalues or remaining-tenor values are already consumed through the oracle. This is unsafe because the read logic now fails closed when a requested tenor is greater than the longest active bucket. If governance shortens a curve from, for example, 180 days to 90 days while a 180-day market still has active exposure, reads for that market can start returning invalid immediately after activation. Consequently, a locally valid bucket update can remove oracle support from a live market. New swaps can fail becausebuySwap()reads the market's fullswapTerm. Active positions can also lose early-exit and LP mark-to-market availability while their remaining tenor is above the new curve maximum. This can block LP share-price reads, LP deposits or withdrawals that depend on active-pool valuation andKairosMarketAdapter.realAssets()for vaults using the affected market.Recommendation
Do not allow a tenor-bucket change to remove terms still required by live markets. Track the maximum required tenor for each oracle or keep an explicit registry of market terms and active remaining-tenor requirements.
activateTenorBucketsChange()should reject a new bucket set whose longest bucket is below any registered live requirement. If tenor removal is needed, keep the old curve available until all dependent exposure has expired or migrated. -
L-23 Low Ghost swap escrow cannot be claimed Unexpected Behavior Reported
Description
KairosBuyerAdapterdocuments that third parties can callSwapCore.buySwap()withonBehalfOf = address(adapter). Those externally funded swaps are owned by the adapter, but they are not inserted into_swapIdsbecause they did not go throughallocate(). That is mostly harmless when settlement pays tokens directly to the adapter, since the token balance is included in_realAssets(). The escrow case is different. If settlement or liquidation cannot transfer the buyer payout to the adapter,SwapCorestores the amount inescrowedCollateral[swapId]and leavessettlementPayouts[swapId]as zero. The adapter is then the only authorized caller that can recover the escrow, butACTION_CLAIM_ESCROWalways calls_removeSwapId(swapId)whensettlementPayouts(swapId) == 0. For a ghost swap,_removeSwapId()reverts withswap not tracked. The revert rolls back theSwapCore.claimEscrow()call, so the escrow remains stuck even after the token can be transferred again. The value is externally funded rather than vault principal, so the impact is limited to donated or mistaken adapter-owned swaps. Still, the contract-level comment says these positions self-heal once proceeds land at the adapter and that is not true when the proceeds first enter SwapCore escrow.Recommendation
Make escrow recovery tolerate adapter-owned swaps that are not in
_swapIds. For example, check whether the swap is tracked before pruning:if (payout == 0 && _isTracked(swapId)) { _removeSwapId(swapId); }Alternatively, reject claims for untracked swaps with a dedicated error before calling
SwapCore.claimEscrow()and document that externally funded adapter-owned swaps are unsupported when payout transfer fails. The safer option is to allow the claim and skip only the tracking cleanup for untracked swaps. -
L-24 Low Long min spacing creates oracle outages Validation Reported
Description
IndexBaseRateV1rejects_minSpacingSecvalues that are too short to retain enough ring-buffer history, but it does not reject values that are too long for the oracle's freshness rule. The constructor only checks_minSpacingSec * (N - 1) >= _twarWindowSec, then seedsmaxLiveGapSecto_twarWindowSec. That leaves a configuration where the left anchor is already too old for_realizedTwar()butpoke()still revertsTooEarly(). For example, with a one-hour TWAR window and seven-hourminSpacingSec, the oracle starts valid. Two hours and one second after deployment, the TWAR boundary is more thanmaxLiveGapSecafter the deploy head, sogetRateByTenor()returns invalid. A keeper still cannot advance the ring because seven hours have not elapsed sincelastAdvanceTimestamp. Consequently, an accepted deployment can create predictable base-rate oracle outages even when the cumulative source is healthy and a keeper is ready to update the oracle.SwapCoretreats invalid base-rate reads as hard failures throughUtils.getBaseRateOracle()andUtils.getBaseRateOracleBatch(), so affected markets can reject buys, early exits and LP mark-to-market reads during those intervals.Recommendation
Reject spacing values that can create an invalid-read interval before the next permitted poke. With the current default freshness bound, require
_minSpacingSec <= 2 * _twarWindowSecor use the stricter operational default_minSpacingSec == _twarWindowSec. If longer spacing is intentionally supported, derive the constructor check from the initial freshness rule:if (_minSpacingSec > _twarWindowSec + _twarWindowSec) revert InvalidSpacing();Keep the existing lower-bound retention check as well, since the short-spacing and long-spacing failures are distinct.
-
L-25 Low BaseSwapRate average accepts bad rates Unexpected Behavior Reported
Description
baseSwapRateWeightedAverage()reads each source throughlatestRoundData(), discards the round metadata and casts the signedanswerdirectly touint256before applying borrow weights. Consequently, a negative source rate is not preserved as a signed value and is not rejected. It is reinterpreted as a huge unsigned integer. A source answer of-0.01e18, for example, becomes almosttype(uint256).maxand can make the weighted result nonsensical. The function also accepts stale or unanswered Chainlink-compatible rounds because it ignoresupdatedAt,answeredInRoundand the other returned metadata. The zero-borrow case is also unsafe as an oracle value. If every configured source reports zero borrow, the function returns1e18, which is a 100% base rate, instead of returning invalid or using a documented fallback. This function is not currently used bygetRate()orlatestRoundData(), which limits the immediate blast radius. The risk is still real for direct consumers ofbaseSwapRateWeightedAverage()and for any future change that wires this helper into the live oracle read.Recommendation
Keep the weighted-average calculation signed from source read through final result. Reject or return invalid when a source round is stale, unanswered or otherwise unhealthy. At minimum, validate
updatedAt != 0andansweredInRound >= roundIdfor Chainlink-compatible sources before using the answer. Define the zero-borrow behavior explicitly. Safer options are returning invalid, using a configured last-good value or using a clearly documented fallback rate. Do not return1e18unless a 100% rate is intentionally part of the oracle design. -
L-26 Low ProtocolFeeForegone omits fee context Events Reported
Description
ProtocolFeeForegoneis used for more than one kind of waived fee, but the event only reportsmarketId,payerandamount.SwapCore.createMarket()emits this event when a market-creation fee cannot be delivered to the protocol multisig.Utils.distributeProtocolFee()emits the same event when a swap protocol fee cannot be delivered. Those two cases need different accounting context, but the event does not identify the fee class, fee token, intended recipient or the paired market created alongside the reported market ID. Consequently, an event-only accounting or monitoring system cannot reliably classify the waived fee. It can record a foregone market-creation fee as a swap protocol fee, assign the amount to the wrong token or attribute a paired market-creation fee to only one market. On-chain balances are not corrupted, so the impact is limited to revenue reconciliation, dashboards, alerts and downstream monitoring that relies on logs. The adjacentMarketCreationFeeCollectedevent includes both market IDs and thefeeTokenfor the successful creation-fee case. The foregone creation-fee case loses those same fields.Recommendation
Use separate events for separate fee classes, for example
ProtocolSwapFeeForegoneandMarketCreationFeeForegone. The market-creation event should include both market IDs, the creator,feeToken,amountand the intended protocol recipient. If preserving one event is required, add a fee-type enum, fee token, recipient and optional paired-market ID so indexers can reconstruct the waived fee from logs alone. -
L-27 Low Permissionless markets bloat required tenors Validation Reported
Description
UpgradableTenorRateOracle.setRequiredTenors()accepts an arbitrary-length tenor array. It stores the full array and immediately validates the whole set against the current downstream oracle._validateDownstreamHealth()then performs one single-tenor read for the default tenor and every required tenor, followed by a batch read over the same full list. This becomes fragile because market creation is permissionless.SwapCore.createMarket()lets any caller create markets against a shared base-rate wrapper with distinctswapTermvalues. Each non-terminated market keeps constraining that wrapper even if it has no locked collateral. Since only the market owner can terminate the market, governance cannot prune attacker-created terms on its own. Consequently, a griefer can create many idle markets that force the wrapper's required-tenor maintenance set to grow. If governance keeps the set complete,setRequiredTenors()and later downstream activation must scan an increasingly large list in one transaction. If governance omits attacker-created terms, the replacement check no longer proves that the downstream oracle serves every live market term.Recommendation
Do not make wrapper safety depend on an unbounded manually supplied tenor array. Cap the number of required tenors, deduplicate entries before storing them and reject new markets that would exceed the cap for a shared wrapper. If arbitrary market creation must remain open, add a way for governance to exclude or retire idle markets from required-tenor maintenance when they have no locked collateral and no active LP exposure.
-
L-28 Low ReferenceRate ignores configured rates Unexpected Behavior Reported
Description
ReferenceRate.setReferenceRate()writesnewRateinto_referenceRate, but no public read uses_referenceRate.referenceRateWad()instead derives a value between 5% and 10% fromkeccak256(abi.encodePacked(block.timestamp / 300, address(this)))andlatestRoundData()plusgetRate()both forward that computed value while reporting it as valid. Consequently, the oracle owner cannot set the rate thatSwapCoreconsumes throughIRateOracle.getRate(). Markets that use this oracle withSpotRateorSpotCompoundRateaccrue their floating index from a predictable time/address formula rather than from the configured reference rate or an external source. This creates a concrete timing profit when the market index is idle.RateIndexLib.update()applies the current spot rate to the whole elapsed period since the last update. A normal user can therefore compute a future high five-minute slot, open aBUY_FIXEDswap exactly one term before that slot, avoid intermediate index updates and settle at the chosen timestamp. The first settlement update then applies the high pseudo-rate to the whole swap term. If the fixed/base rate is lower, the buyer receives LP collateral even though no external reference market ever produced that realized rate. This also contradicts the protocol assumption that oracle rates are external trusted inputs and that Kairos does not use pseudo-random number generation for rates.Recommendation
Remove the pseudo-random calculation from
referenceRateWad()and return_referenceRateinstead. Keep the storage type signed if negative reference rates are supported by the protocol and havelatestRoundData()andgetRate()forward the same configured value. If this contract is intended only as an unsafe chaos/mock oracle, make it impossible to use in market creation for non-test deployments or rename and document it as unsafe. For deployed spot-rate markets, also avoid treating one endpoint spot reading as the realized rate for a stale interval. Use cumulative/checkpointed reference-rate sources or tightly bound how much elapsed time one spot update may backfill. -
L-29 Low PythAdapter rejects fresh zero-percent rates Validation Reported
Description
PythAdapter.getRate()treats any Pyth price equal to zero as invalid. It returns(0, false)before freshness and confidence checks can distinguish a stale or missing feed from a fresh feed whose rate is exactly 0%. For rate feeds, 0% can be a valid value. Pyth's localgetPriceNoOlderThan()implementation already rejects stale data by checkingpublishTimeagainst the requested age andPythAdapterseparately rejectspublishTime == 0, future timestamps, stale timestamps, low-confidence readings and unscalable values. Consequently, aSpotRatemarket that usesPythAdapteras its reference oracle becomes temporarily unusable while the external feed reports exactly 0%.RateIndexLib.update()receivesisValid == falseand reverts withE606, soupdateMarketRateIndex(),buySwap()and LP deposits that callsupplyCollateral()cannot proceed even though the oracle update is fresh.Recommendation
Accept zero as a valid signed rate when the Pyth update has a nonzero, nonfuture, fresh
publishTime, passes the configured confidence check and can be represented at the target scale. Remove the unconditionalpythPrice.price == 0invalidation. If a scaled zero can also result from truncating a very small nonzero feed, handle that case separately from an exact zero rate so legitimate 0% updates remain usable. -
I-01 Informational Adapter NAV omits idle deallocated cash Reentrancy Acknowledged
Description
During a partial LP deallocation,
KairosMarketAdapter.deallocate()callsSwapCore.withdrawCollateral()with the adapter as the receiver.SwapCore.withdrawCollateral()debits the adapter's LP shares and reducespool.totalCollateral, then transfers the withdrawn asset to the adapter. For a callback-capable market asset, that transfer can reenter external vault logic after the withdrawn tokens have been credited to the adapter, but beforeadapter.deallocate()returns and before the vault performs its post-returntransferFrom. At that point,KairosMarketAdapter._realAssets()values only the remaining LP shares:assets = Math.mulDiv(pos.shares, sharePrice, WAD);It does not add
asset.balanceOf(address(this)). The withdrawn collateral is therefore real adapter-owned value, but it is invisible torealAssets()during the callback window.Recommendation
Remove or guard the callback-observable window. The safest options are to support only non-callback market assets for this adapter, or require the parent vault to block reentrant deposit, mint, allocation and deallocation flows while adapter deallocation is in progress. If callback-capable assets must be supported, add narrowly scoped in-flight withdrawal accounting or a deallocation reentrancy guard so
realAssets()cannot be observed in the reduced-shares, pre-pull state. -
I-02 Informational Split exits round away swap fees Rounding Acknowledged
Description
calculateEarlyExitSettlementcomputesearlyExitFee = (netObligation * market.earlyExitFee) / WADfor one swap at a time. The division floors the result in the market token's smallest unit.exitSwapEarlyaccepts an array of exits, but it marks each swap and then settles each swap independently through_settleSwaps. There is no aggregation of a user's exits before the fee is calculated. A trader can therefore split one aggregate exposure into many swaps whose individual fee products are below one token unit, causing every per-swap fee to round to zero.Recommendation
Accumulate fees at higher precision or compute them over aggregated exits per user/market. Alternatively round non-zero early-exit fees up or enforce token-aware minimum notional/net-obligation thresholds so per-swap truncation is immaterial.
-
I-03 Informational Callback credits wrapped payout early Reentrancy Resolved
Description
Utils.settleSwapsetsswap.settled = truebefore it transfers the settlement token to the swap owner. For wrapped positions, that owner isSwapPositionWrapper.SwapCore._settleSwapsonly writessettlementPayouts[swapId]afterUtils.settleSwapreturns. If the market token executes recipient code duringtransfer, a third-party direct call toSwapCore.makePaymentcan enterSwapPositionWrapper.settle(tokenId)from inside the settlement transfer. At that point the wrapper observessettled == true, skips its ownmakePaymentcall, reads the still-zerosettlementPayouts[swapId]and sets_credited[tokenId] = truewithout increasingredeemable[tokenId]. The outer settlement then finishes and records the real positive payout insettlementPayouts[swapId], but the wrapper has already marked the NFT as credited. Latersettleorredeemcalls are idempotent no-ops for crediting, so the NFT remains redeemable for zero.Recommendation
Write
settlementPayouts[swapId]before any external token transfer can reenter wrapper crediting logic or make wrapper crediting depend on a finalized payout flag that is distinct fromswap.settled. Alternatively, explicitly reject callback-capable settlement tokens at market creation. -
I-04 Informational Split swaps bypass aggregate collateralization Rounding Resolved
Description
calculateBuyerCollateral()andcalculateLPCollateral()calculate opening margin for one swap at a time and round the token amount down.SwapCore.buySwap()then treats each rounded value as fully collateralized as long as it is at leastminCollateral. This lets a buyer capture the discarded fractional collateral by splitting one exposure into many small swaps. The aggregate position can require materially more collateral under the same formula than the sum of the independently rounded split positions. For example, in a zero-decimal market with a 5% base rate, one-year term, 1x leverage, no utilization fee, no risk premium andminCollateral = 1, a39notional swap has an exact buyer and LP collateral requirement of1.95tokens. Both functions return1. Repeating that opening 99 times locks only99pool tokens while creating3,861notional. The same aggregate notional would require193LP collateral tokens under the unsplit formula. Consequently, low-decimal markets or markets with an economically smallminCollateralcan allow more notional per unit of collateral than intended. If rates later move in the buyer's favor, the buyer can extract more LP backing than the aggregate collateral model should have allowed. The effect is usually negligible for conventional 6- or 18-decimal assets whenminCollateralis set well above one atomic unit.Recommendation
Round buyer collateral and LP collateral up whenever a nonzero exposure requires a fractional token amount. Prefer a single full-precision calculation with ceiling rounding for the whole expression:
ceil(notional * rate * term / (leverage * SECONDS_IN_YEAR))Keep the inverse available-liquidity calculation conservative by rounding down.
minCollateralshould remain a token-specific safety floor, but it should not be the only protection against repeated per-swap rounding loss. -
I-05 Informational Fee receiver can block market actions Unexpected Behavior Resolved
Description
SwapCoredelivers protocol fees synchronously while executing user operations. DuringcreateMarket(), if a native market-creation fee is active, the function sends ETH toadmin.getProtocolMultisig()and reverts withE151when the receiver call fails. DuringbuySwap(), the buyer's protocol fee is first pulled intoSwapCore, thenUtils.distributeProtocolFee()pushes the protocol share toprotocolMultisigwithsafeTransfer. Any token revert from that transfer reverts the entire swap opening. This makes the configured fee receiver a hard liveness dependency for unrelated users. A multisig contract can be valid when configured and later become unable to receive ETH, for example because its fallback changes or rejects direct transfers. For ERC20 protocol fees, a market token can later reject or blacklist transfers to the multisig. In both cases, the caller cannot work around the receiver failure from the affected operation. Consequently, fee-bearing market creation can be disabled by a rejecting native-fee receiver and fee-bearing swap opening can be disabled for any market token that refuses protocol-fee delivery to the multisig. Recovery requires admin action to disable the fee or rotate to a receiver that accepts the asset, even though market creators and buyers supplied valid inputs. The creator-fee portion already treats receiver failure as non-fatal: if the market owner cannot receive its share, that share stays with the protocol amount. The protocol-fee portion does not have the same protection, so the passive protocol receiver can make user-critical operations unavailable.Recommendation
Do not require fee delivery to the protocol multisig to succeed inside
createMarket()orbuySwap(). Account for the fee during the user operation, store claimable balances by asset and recipient and let the current multisig claim fees separately. A failed claim should leave the accrued balance intact and should not revert market creation or swap opening. This keeps fee accounting complete while removing the receiver as a synchronous liveness dependency. -
I-06 Informational Unset Morpho ids report zero rates Validation Resolved
Description
ReferenceRateMorphodoes not verify thatmarketIdbelongs to a created Morpho market._getRate()readsidToMarketParams(marketId)andmarket(marketId), but it treats the default zero values as a valid zero-rate market. WhenmarketParams.irm == address(0), the function returns0.AbstractReferenceRate.getRate()then wraps that answer as(0, true). DuringSpotRateorSpotCompoundRatemarket creation,RateIndexLib.initialize()only checks the returned validity flag. It does not know whether the Morpho market exists, has a nonzero IRM or has ever been initialized. Consequently, a typo, stale deployment config or permissionless market creator can publish a Kairos market whose reference oracle points at an uncreated Morpho market. The reference index then starts from a fresh-looking 0% rate instead of failing closed. If LPs later fund the market, buyers can trade against a flat reference index that does not represent any real Morpho source market.Recommendation
Reject uncreated Morpho markets during construction or during every read. At minimum, check
morpho.market(marketId).lastUpdate != 0before returning a valid rate. Also propagate invalidity through the reference-rate interface. If the Morpho market is missing,getRate()should return(0, false)or the constructor should revert, instead of returning a valid zero rate. A genuinely created zero-IRM market can remain valid if it has a nonzero created-market signal. -
I-07 Informational BaseSwapRate cannot serve SwapCore Warning Acknowledged
Description
BaseSwapRatepresents itself as a base-rate oracle, but it only implements the single-rateIRateOracleinterface. It exposesgetRate(),latestRoundData(),decimals()anddescription(). It does not implementITenorRateOracle.getRateByTenor()orITenorRateOracle.getRates()and it does not advertiseITenorRateOraclethrough ERC-165.SwapCorerequires the base-rate oracle to support those tenor-dependent reads during market creation._initializeMarket()callsUtils.probeOraclesForMarketCreation(), which castsbaseSwapRateOracletoITenorRateOracleand calls bothgetRateByTenor(swapTerm)andgetRates([swapTerm]). BecauseBaseSwapRatehas neither selector and no fallback, a market that uses it asbaseSwapRateOraclereverts during creation. Consequently, operators cannot useBaseSwapRatein the base-rate slot despite the contract name and its otherwise valid single-rate oracle surface. Any deployment or integration that selects it forbaseSwapRateOraclewill fail before the market is created.Recommendation
If
BaseSwapRateis intended to be usable as aSwapCorebase-rate oracle, implement the fullITenorRateOracleinterface.getRateByTenor()should return the configured base rate for the requested tenor andgetRates()should return one value per requested tenor in the same order. If the contract is only intended for single-rate reference or risk-premium use, rename it or document that it must not be supplied asbaseSwapRateOracle. -
I-08 Informational Cached oracle health is skipped Validation Resolved
Description
RateIndexLib.initialize()validates a reference oracle only the first time that oracle address is used. WhenlastUpdates[oracle]is already nonzero, the function only checks that the storedRateConventionmatches the requested convention. It does not read the oracle again or verify that the oracle is currently healthy.SwapCore._initializeMarket()relies on this function as the reference-oracle health check during market creation. Therefore, once one market has initialized a reference oracle, later markets can reuse the same oracle address even if it is now paused, stale, under an emergency cutoff or otherwise returningisValid = false. Consequently, a new market pair can be recorded and its creation fee can be collected while its reference oracle is unusable. The market then appears valid to indexers, adapters and users, but rate-dependent actions such as LP deposits, swap entry, liquidation, settlement refreshes and active-pool valuation can revert until the oracle recovers.Recommendation
Probe the reference oracle during every market creation, even when its rate index has already been initialized. Keep
RateIndexLib.initialize()idempotent for stored index setup, but add a separate current-health check inSwapCore._initializeMarket()after any configured oracle poke and before the market creation completes. ForCumulativemarkets, requiregetCumulativeIndex()to return a nonzero valid index. ForSpotRateandSpotCompoundRatemarkets, requiregetRate()to returnisValid = true. -
I-09 Informational Redeem callback can sell live NFT Reentrancy Resolved
Description
SwapPositionWrapper.redeem()transfers the redeemed settlement tokens before it burns the ERC721 position token. The wrapper deletes its accounting first, then callssafeTransfer(msg.sender, amount)and only after that calls_burn(tokenId). If the settlement token can execute recipient code during transfer, the redeeming holder can run code while the NFT still exists. The wrapper'snonReentrantmodifier protectsredeem()itself, but it does not protect the inherited ERC721transferFrom()andsafeTransferFrom()functions. A contract holder can therefore transfer the still-live NFT during the token callback. Consequently, a malicious holder can redeem the position, receive the payout and then sell or transfer the NFT during the payout callback. When control returns toredeem(),_burn(tokenId)burns the token from its new owner. A buyer that accepted the NFT in the same transaction can pay for an asset that is destroyed before the transaction completes. This requires a callback-capable settlement token and an atomic sale or standing bid that can be filled from the callback. The issue is therefore low severity unless those token and marketplace assumptions are part of an intended deployment.Recommendation
Burn the NFT before transferring settlement tokens or otherwise make the token non-transferable once redemption starts. A minimal fix is to call
_burn(tokenId)immediately after the ownership and settlement checks, before any external token transfer is attempted. If the contract must keep the burn after payout for compatibility reasons, override ERC721 transfer and approval functions so a token marked as redeeming cannot be transferred or approved during redemption. -
I-10 Informational Tenor cardinality can exhaust gas Gas Optimization Resolved
Description
BaseTimelockedOracleaccepts any non-empty sortedtenorBucketsarray. The constructor andsetTenorBuckets()do not enforce a maximum number of buckets. That matters because every successful oracle update must include the complete active tenor set._validateTenorsAndCadence()requires the postedtenorsarray to have exactly the same length and order astenorBuckets.FixedRatePricingOracle.postForecasts()andRiskRateOracle.postRiskPremiums()then validate and store each entry in that full vector. The read side also scales poorly with the configured curve size._getRatesInternalWith()first scans every configured bucket to find the earliest timestamp and then scans every bucket again to copy rates into memory. After that, each requested tenor calls_interpolateFromMemory(), which scans the full bucket list once more. This makes batch reads grow withbucketCount * requestedTenors. Consequently, a syntactically valid tenor expansion can make the oracle impractical to reseed after activation because a complete signed post may no longer fit within the available gas budget. Smaller but still oversized curves can also make multi-tenor base-rate reads expensive enough to break LP share pricing.Utils.calculateTotalUnrealizedPnL()can request one tenor for each active market bucket, so active-pool deposits, withdrawals, share-price reads andKairosMarketAdapter.realAssets()inherit the inflated oracle cost.Recommendation
Add a maximum tenor-bucket count and enforce it in both the constructor and
setTenorBuckets(). Size the cap against the worst-case first post forFixedRatePricingOracleandRiskRateOracle, plus the largest expected production batch read from LP valuation. Also consider replacing_interpolateFromMemory()with binary search or with a sorted one-pass merge for batch reads. That keeps multi-tenor reads from multiplying the full curve length by the number of requested tenors. -
I-11 Informational Pool-side self-liquidation captures reward Warning Acknowledged
Description
Utils.liquidateSwappays the pool-side liquidation reward to the unrestrictedliquidatoraddress and pays the remaining pool backing toswap.userAddress.SwapCore.liquidateSwappassesmsg.senderasliquidator. Neither function rejectsmsg.sender == swap.userAddress. Consequently, a buyer can wait until their in-the-money swap crosses the pool-side liquidation threshold, then callliquidateSwapon their own position. The buyer receives the normal buyer payout and the keeper reward. This gives the buyer more than the live accrued claim and more than the liquidation-aware buyer value that the protocol reports before liquidation.Recommendation
Reject self-liquidation when the pool side is liquidatable or do not pay the keeper reward when
liquidator == swap.userAddress. The simplest fix is to require a third-party liquidator for pool-side liquidations before any state is mutated. If self-liquidation should remain possible, settle the buyer atpoolBacking - liquidatorRewardand leave the reward in the pool instead of transferring it to the buyer. -
I-12 Informational Unbounded poke spacing disables refresh Validation Reported
Description
Admin.setPokeMinSpacingSec()only enforces a 60 second lower bound. It does not enforce any upper bound, so the owner can setpokeMinSpacingSecto a value such astype(uint32).max.SwapCorereads this value live and passes it toOraclePokeLib. That library skips a poke wheneverblock.timestamp - lastPokes[oracle] < minSpacingSec. Therefore, an accidentally oversized value can suppress protocol-triggered pokes for years after an oracle has been poked once. This is an owner misconfiguration issue which impact is still relevant for poke-dependent base or risk oracles because automatic refreshes can stop working. Affected markets can then reject swaps, exits, settlements or NAV reads once those oracles become stale or invalid unless an out-of-band refresh recovers them.Recommendation
Add a maximum value for
pokeMinSpacingSecand reject larger updates. Size the ceiling to the slowest supported oracle cadence, for example a few hours or one day. Consider timelocking large increases while still allowing decreases immediately. -
I-13 Informational Split buys avoid fees via rounding Rounding Reported
Description
buySwapcomputes the upfront protocol fee independently for each swap and rounds the result down:protocolFee = Math.mulDiv(notionalAmount, feeRate * swapTerm, SwapFormulas.SECONDS_IN_YEAR * SwapFormulas.WAD);A buyer can split one intended exposure into smaller buys whose individual fees are each below one raw token unit. Each buy then records
protocolFee == 0, even though the same aggregate notional would pay a nonzero fee. The dust guard does not prevent this in all markets.createMarketonly requiresminCollateral > 0andbuySwapchecksminCollateralagainst the rounded-up buyer and LP collateral, not against the protocol fee. Therefore low-decimal markets or markets with small raw token units, can accept trades that satisfy the collateral floor while still rounding the protocol fee to zero.Recommendation
Do not allow nonzero fee configurations to round to zero on accepted buys. A minimal fix is to round the protocol fee up when
feeRate > 0or to requireprotocolFee > 0for fee-enabled markets unless the protocol explicitly supports fee-free dust trades. If rounding up by one raw unit is too punitive for high-decimal tokens, enforce a market-level minimum notional or minimum protocol fee that is derived fromfeeRate,swapTermand token decimals. Add a regression test that compares an aggregate buy with split buys and asserts the split sequence cannot pay less total protocol fee for the same total notional. -
I-14 Informational SpotCompound snapshots truncate extreme indexes Warning Reported
Description
RateIndexLib._appendSnapshot()stores each historical index asuint208(val)without checking whether the liveuint256index fits. This is unsafe forSpotCompoundRatemarkets.indexFromCumulativeRateTimeCompound()can produce indexes abovetype(uint208).maxbefore its explicit exponent guard reverts, so the live index remains correct while the historical snapshot silently keeps only the low 208 bits. Normal expiry settlement uses the historical snapshot, not the liveuint256index._settleSwaps()resolvesexpiryIndexwithgetIndexAt()andUtils.settleSwap()passes that value intoSwapFormulas.deriveRateFromIndex(). Therefore a truncated expiry snapshot understates the realized floating rate. ABUY_FLOATINGbuyer who should approximately net to zero against the fixed leg can instead receive LP collateral, capped only byswap.poolCollateralBacking. The trigger is an extreme-rate or long-duration prerequisite, not an ordinary market condition. The snapshot width is crossed only after roughly 82 WAD-years of accumulated compound rate-time. For example, that means about 100% APR for 82 years, 500% APR for 16.4 years or 10,000% APR for 0.82 years. This makes the likelihood low for normal Aave, Compound and similar rate markets, but the code does not enforce a bound that keepsSpotCompoundRatesnapshots insideuint208. A local test used the high-rate case to cross the boundary quickly: a one-year term and a 100 WAD reference/base rate. This rate is accepted by the market and oracle bounds and remains below the exponent overflow guard. At maturity, the live index is2.688e70, but the stored expiry snapshot is the truncateduint208value4.020e62. Settlement derives an81.98WAD floating rate instead of the correct100WAD rate and transfers all5,555,555.555556USDC of locked LP backing to the buyer.Recommendation
Do not silently narrow settlement-critical index history. Store snapshot indexes as
uint256or fail closed before appending when the computed index does not fit the packed snapshot type. If packed storage must be retained, capSpotCompoundRatemarket parameters and oracle updates so every possible settlement index is guaranteed to fit inuint208.
No findings match.
More from Kairos
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.