Guardian's review of Protocol Review for Avon, published May 2026. The report records 45 findings across 6 review rounds, including 1 high and 18 medium.
- Published
- Review window
- November 24, 2025 to April 30, 2026
- Rounds
- Main Review, Remediation Review, Remediation Review 2, Remediation Review 3, Remediation Review 4, Remediation Review 5
- Language
- Solidity
- Sector
- Lending
- 0 Critical
- 1 High
- 18 Medium
- 19 Low
- 7 Informational
Scope
- avon-xyz/vault-contract 3495c7a80348b3820440
- avon-xyz/avon-core 20c33fcb3bc789959bce
- avon-xyz/avon-periphery 1e58b054500b3254493a
32 files in scope · 3,952 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/VaultV2.sol | 777 | 1306 |
src/adapters/AvonPoolAdapter.sol | 114 | 246 |
src/adapters/AvonPoolAdapterFactory.sol | 16 | 41 |
src/Orderbook.sol | 363 | 596 |
src/OrderbookFactory.sol | 82 | 139 |
src/OrderbookFactoryStorage.sol | 23 | 28 |
src/libraries/AugmentedRedBlackTreeLib.sol | 635 | 978 |
src/libraries/ErrorsLib.sol | 28 | 91 |
src/libraries/EventsLib.sol | 22 | 113 |
src/libraries/MathLib.sol | 25 | 44 |
src/libraries/OrderbookLib.sol | 265 | 429 |
src/pool/AvonPool.sol | 655 | 903 |
src/pool/PoolStorage.sol | 93 | 129 |
src/libraries/LiquidityAllocator.sol | 27 | 44 |
src/libraries/SharesLib.sol | 19 | 44 |
src/factory/AvonPoolFactory.sol | 51 | 98 |
src/factory/VaultFactory.sol | 41 | 81 |
src/oracle/Oracle.sol | 47 | 73 |
src/irm/LinearKinkIRM.sol | 32 | 53 |
src/pool/utils/PoolConstants.sol | 28 | 46 |
src/pool/utils/PoolErrors.sol | 21 | 62 |
src/pool/utils/PoolEvents.sol | 83 | 292 |
src/pool/utils/PoolGetter.sol | 94 | 125 |
src/pool/extensions/AccrueInterest.sol | 56 | 86 |
src/pool/extensions/BorrowRepay.sol | 89 | 152 |
src/pool/extensions/CollateralManagement.sol | 26 | 47 |
src/pool/extensions/DepositWithdraw.sol | 37 | 77 |
src/pool/extensions/FlashLoan.sol | 33 | 54 |
src/pool/extensions/Liquidation.sol | 99 | 154 |
src/pool/extensions/PositionGuard.sol | 30 | 53 |
src/pool/extensions/UpdateOrders.sol | 35 | 50 |
src/pool/extensions/Utils.sol | 6 | 10 |
Findings 45
Main Review
22 findings · November 24 to December 11, 2025-
M-01 Medium Referrer Claim DoS From Rounding Mismatch Rounding Resolved
Description
The referrer rewards system relies on an index-based accounting model to distribute escrowed referrer fee shares. The mechanism allocates newly escrowed shares by increasing a global index (
referrerIndex) and tracking how many shares have been globally allocated but not yet claimed intotalUnclaimedReferrerShares. Both values are updated during index distribution which performs two separate floor-rounded conversionsuint256 indexIncrease = pending.mulDivDown(WAD, stake); referrerIndex += indexIncrease; totalUnclaimedReferrerShares += indexIncrease.mulDivDown(stake, WAD);A referrer’s personal accrual is computed differently. It accrues new rewards based on the full index delta since their last interaction and apply floor rounding only once.
uint256 delta = idx - lastIdx; uint256 sharesAccrued = stake.mulDivDown(delta, WAD); if (sharesAccrued != 0) { referrerAccrued[referrer] += sharesAccrued; }The issue arises because
totalUnclaimedReferrerSharesis updated by rounding down twice on every index update. A referrer, however, accrues rewards by rounding down only once on the total index change since their last interaction. Because there’s a mismatch between the sum of round-downs and rounding down the sum, the referrer’s computed claimable amount can end up slightly higher thantotalUnclaimedReferrerShares, even though the rewards were escrowed.When a referrer calls
claimReferrerFees, the contract subtracts the referrer’s claimable shares fromtotalUnclaimedReferrerShares. If the referrer’s claimable amount is greater thantotalUnclaimedReferrerShares, the subtraction underflows and the transaction reverts. As a result, affected referrers will be unable to claim their rewards.Recommendation
Carry forward the discarded fractional remainder in each index update and transfers only the safely claimable amount instead of reverting when the computed claim exceeds
totalUnclaimedReferrerShares. Optionally, consider allowing referrer to provide the intended amount of referrer fee. -
M-02 Medium Inverted Slippage Guard In _borrow Function Logical Error Resolved
Description
In the borrow path, when the exact amount of asset is provided, the internal
_borrowpath computes the borrower’s debt shares from the requested assets and then enforces that the amount of shares must be greater or equal tominExpected, treating this as a minimum tolerable number of debt shares. Since debt shares represent liability, this check is inverted - it causes transactions to revert when the user would receive fewer shares than expected (better terms) while allowing execution when they receive more shares than expected (worse terms), meaningminExpectedcannot be used to cap the borrower’s maximum debt and instead only blocks favorable outcomes.Recommendation
Replace the minimum shares check with a maximum debt-share cap so the call reverts whenever calculated debt shares exceed that bound.
-
M-03 Medium EIP-4626 Previews Skew With Interest Accrual Unexpected Behavior Resolved
Description
In AvonPool, the state-changing
depositandmintfunctions callaccrueInterestbefore delegating to the counterpart functions defined in ERC4626, whilepreviewDepositandpreviewMintare only inherited and do not account for this interest accrual. This means that within a single transaction an integrator can observepreviewDepositoutput on pre‑accrual state and then call deposit, which will mint fewer shares than the preview predicted. Similarly,previewMintcan return fewer required assets than mint actually pulls after accrual.This violates EIP-4626’s MUST‑level requirements that, in the same transaction,
depositmust always return the same or more shares thanpreviewDepositand mint must always consume the same or fewer assets thanpreviewMint, and can mislead or break integrators relying on these bounds for slippage and safety checks.Recommendation
Override
previewDepositandpreviewMintto use the same preview‑accrued totals thatdepositandmintuse afteraccrueInterest, keeping the previews consistent with actual execution. -
M-04 Medium Underwater Liquidations Inflate Bad Debt Logical Error Resolved
Description
The liquidation mechanism allows partial liquidations of underwater positions to decrease their health score, meaning each liquidation step removes more collateral value than debt value repaid when the liquidation bonus exceeds the current collateral-to-debt ratio.
Health score is defined as
(LLTV * collateralValue) / borrowAssetsand a liquidation that repays some amount of debt seizes collateral worthbonusFactor * debt. When the liquidation bonus factor is greater thancollateralValue / borrowAssets, the collateral value drops faster than the debt, so the post-liquidation health score will be lower than before.As a result, the collateral/debt coverage ratio gets strictly worse after a partial liquidation, so if prices continue to move against the collateral, this will increase the amount of bad debt ultimately socialized to LPs. This behaviour contradicts the invariant that liquidation should improve a position’s health, and creates opportunity to game this mechanism by executing small partial liquidations that push positions deeper underwater, amplifying future bonuses and extracting more value from borrower collateral and worsening the eventual losses absorbed by LPs.
Recommendation
Clamp the effective liquidation bonus to at most the current collateral‑to‑debt ratio, so a partial liquidation can only keep the health score the same or improve it.
-
M-05 Medium Repay Cleanup Breaks Borrow Share Accounting DoS Resolved
Description
The repay function includes a cleanup condition that sets s.totalBorrowShares to zero whenever s.totalBorrowShares is greater than zero while s.totalBorrowAssets is zero. This is intended to prevent leaving residual global borrow shares after all borrow assets have been fully repaid.
However, due to the rounding direction in repay and liquidation flows, users can slightly overpay assets, causing the system to reach a state where the cleanup branch zeroes out
s.totalBorrowShareseven though at least one position still holds non-zeroborrowShares.This creates an accounting desync: the sum of all user borrowShares becomes strictly greater than the global totalBorrowShares. Once the system enters this state, any attempt to fully repay or liquidate the final remaining borrower will revert due to an underflow when subtracting from totalBorrowShares.
Recommendation
Update or remove the cleanup logic so that s.totalBorrowShares is only zeroed when both global borrow assets and all per-position borrowShares have reached zero.
-
M-06 Medium Referrer Can Be Forced Via Zero-Share Transfer Logical Error Resolved
Description
The transfer function updates referrer associations whenever shares move between accounts. If the recipient has no referrer set and the sender has a whitelisted referrer, the recipient automatically inherits the sender’s referrer.
This behaviour can be abused to force a referrer onto any user. An attacker can simply send a zero-share transfer to a user who has not yet set a referrer. That user is then assigned the attacker’s referrer. When the user later performs a deposit and specifies their own referrer through the depositWithReferrer function, the protocol ignores the supplied referrer because the user already has one set. Since referrers cannot be changed once assigned, the user is permanently locked with the initial referrer.
Recommendation
Restrict referrer assignment so it only occurs on explicit referrer-setting calls. This prevents attackers from forcing referrers via zero-value or dust transfers.
-
M-07 Medium Over-Seizure When repaidShares Is Clamped Logical Error Resolved
Description
In the _liquidate function, when liquidating with the seizedAssets parameter, the function first derives repaidShares from the proposed seizedAssets (after applying seizeCap) using the configured liquidation bonus. Later, it clamps repaidShares to position.borrowShares, but does not recompute seizedAssets after this clamp.
As a result, in edge cases where the initial calculation would repay more than the remaining debt, the liquidator still seizes the full seizedAssets amount while only repaying the reduced, clamped debt. This allows a liquidator to over-seize collateral beyond what corresponds to the final repaid debt and bonus, causing additional value loss for the borrower.
Recommendation
Whenever repaidShares is reduced (for example, clamped to position.borrowShares) in the seizedAssets path, recompute seizedAssets from the final repaidShares.
-
L-01 Low Unaccounted Fees When feeRecipient Is Zero Logical Error Resolved
Description
In
_flashLoanLoanToken, the total flashloan fee is split intopoolFeeAmountandprotocolFeeAmountand onlypoolFeeAmountis added tos.totalSupplyAssets. IfprotocolFeeAmountis greater than 0 butfeeRecipientis address(0), the transfer is skipped and the fullfeeAmountstays in the vault’s token balance, but onlypoolFeeAmountis reflected ins.totalSupplyAssets. This leaves orphaned tokens inside the pool that are neither claimable by LPs nor withdrawn as protocol fees, breaking the expected accounting invariant and effectively locking part of the flashloan fees inside the contract.Recommendation
Ensure the protocol fee portion is consistently accounted by either adding
protocolFeeAmounttos.totalSupplyAssetswhenever it remains in the pool instead of being transferred out or reverting whenprotocolFlashLoanFeePercentage > 0 && feeRecipient == address(0). -
L-02 Low Inconsistent Interest Projections While Paused Logical Error Resolved
Description
When the pool is paused, interest accrual is applied to the pause timestamp and then blocked, but
_previewAccrueInterestand the view functions that rely on it continue to compute interest usingelapsed = block.timestamp - s.lastUpdateas if the pool were still accruing normally. On unpause,pausePoolfunction setss.lastUpdate = block.timestampwithout applying that projected interest, so all interest previewed during the paused interval is effectively discarded. This creates a discrepancy where views overestimate debt and yield while paused, and then return to lower, actually-accrued values once the pool is resumed.Recommendation
Make preview logic respect the paused state by treating
elapsedas zero while the pool is paused. -
L-03 Low Deposit Cap Not Reflected Informational Partially resolved
Description
AvonPool enforces deposit cap inside the deposit and mint functions, via an internal check. However, the contract inherits the default ERC4626 implementations of
maxDepositandmaxMint, which both returntype(uint256).max. As a result, off-chain integrators that follow the standard pattern of queryingmaxDeposit(receiver)/maxMint(receiver)to determine the maximum safe amount will be told they can deposit/mint arbitrarily large amounts, even though such calls can revert at runtime once the internal cap check is hit.Recommendation
Override
maxDepositandmaxMintin AvonPool so they reflect the effectivedepositCapor clearly document for integrators that these view functions do not enforce the pool’s deposit cap. -
L-04 Low No Slippage Bounds On ERC4626 Flows Unexpected Behavior Resolved
Description
AvonPool exposes the standard ERC4626
deposit,mint,withdraw, andredeemfunctions but does not provide wrapper functions that allow callers to specify minimum/maximum bounds on shares or assets, even though EIP4626 recommends such variants when supporting direct EOA usage.As a result, users and integrators have no built-in way to protect themselves from slippage and unexpected exchange rate changes on these operations, increasing the risk of unintentionally receiving fewer assets or shares than intended.
Recommendation
Consider adding slippage-aware wrapper functions for
deposit,mint,withdraw, andredeemthat accept min/max asset or share bounds and revert if these bounds are violated. -
L-05 Low Pool Lacks Support Configuration Acknowledged
Description
The Avon pool contract does not support non-standard ERC20 tokens. For instance tokens that take fees on transfer, since those fees break the expected accounting. In addition, tokens with callbacks can trigger reentrancy during deposit or borrow operations and bypass internal caps, allowing users to manipulate balances or exceed intended limits.
Recommendation
Ensure the pool contract only accepts standard ERC20 tokens.
-
L-06 Low previewBorrow Ignores Borrow Cap Validation Resolved
Description
The previewBorrow function does not account for the protocol’s borrowCap when estimating how much a user can borrow. As a result, callers relying on previewBorrow may receive a borrow estimate that exceeds the actual allowable amount, causing transactions to revert when the real borrow call enforces the cap.
Recommendation
Update previewBorrow to include the borrow-cap constraint so previews match the limits enforced during actual borrowing.
-
L-07 Low PreviewBorrow Includes Paused Pools Validation Resolved
Description
The previewBorrow function doesn't skip paused pools, even though the matching logic ignores them during actual matching execution. As a result, previewBorrow can return estimates that rely on liquidity from pools that will not be used, causing the previewed results to differ from real outcomes.
Recommendation
Update previewBorrow to filter out paused pools in the same way the matching logic does, ensuring previews align with executable behaviour.
-
L-08 Low Stale Orders After Borrow Cap Update Logical Error Resolved
Description
The _executeUpdateBorrowCap function updates the borrow cap, which is then used by _updateOrders to compute available liquidity for quoting and maintaining the orderbook. Because _executeUpdateBorrowCap does not call _updateOrders after applying the new cap, the existing orders remain based on outdated liquidity assumptions. This can leave quotes in the orderbook that are no longer fillable under the updated cap.
Recommendation
Call _updateOrders after applying a borrow cap update so the orderbook reflects the new available liquidity.
-
L-09 Low maxWithdraw and maxRedeem Ignore Pool Constraints Unexpected Behavior Resolved
Description
AvonPool adds extra withdrawal constraints (liquidity checks against
totalBorrowAssetsand thewhenNotPausedmodifier) in withdraw and redeem function, but inherits the default ERC-4626 implementations ofmaxWithdrawandmaxRedeem, which only look at the user’s share balance and the conversion rate. As a result,maxWithdrawandmaxRedeemcan report amounts that exceed what the pool actually can serve (when liquidity is constrained by borrows or the pool is paused), breaking the EIP-4626 requirement that these functions must factor global limits and never return a value higher than the actual maximum and skewing integrators’ assumptions when they treat these as safe upper bounds.Recommendation
Override
maxWithdrawandmaxRedeemso they incorporate liquidity constraints and pause state. -
L-10 Low Paused Pools Block Fills During Matching Logical Error Resolved
Description
matchMarketBorrowOrderandmatchLimitBorrowOrderboth skip paused pools when calling_aggregatePoolData, ensuring that paused pools do not contribute liquidity to the final borrow result. However, earlier in the flow, both functions call_matchOrder, which iterates through lender orders and attempts to fill the borrower’s request.The issue is that
_matchOrderdoes not check whether a pool is paused. If it encounters an order from a paused pool, it will attempt to match it and count that amount toward the borrower’s progress. Later,_aggregatePoolDatacorrectly excludes that pool, causing the matched amount to be discarded.As a result, a borrower may receive only a partial fill even when sufficient liquidity exists in active pools. If the borrower uses a loose
minAmountExpected, the call may still succeed but unnecessarily charge the flat matching fee despite having no actual fill. In cases where a paused pool has deep liquidity and good rates but its orders remain on the book, it can consistently block full matches.In principle, pools should cancel all orders before pausing. However, if cancellation fails or an order remains for any reason, the stale order can still be picked up by
_matchOrder, triggering this inconsistency.Recommendation
Ensure _matchOrder excludes paused pools in the same way _aggregatePoolData does, so that paused pools cannot interfere with match progression.
-
L-11 Low Incorrect loanTokenAmount Logical Error Resolved
Description
In
previewBorrowfunction, whenpreviewBorrowParams.isCollateral == false, the function correctly computesPreviewMatchedOrderandcollateralRequiredfrom the matched orders, but never updatesloanTokenAmount, leaving it at its default value of 0 even whentotalMatchedis greater than 0, which makes the returned data inconsistent with the actual matches and can mislead integrators that rely onloanTokenAmountto understand how much loan liquidity is actually available. The function’s NatSpec explicitly states thatloanTokenAmountis “The total amount of loan tokens that would be received”, which is also meaningful in theisCollateral == falsecase, and returning 0 there is inconsistent with that documented behavior.Recommendation
Consider updating
previewBorrowso thatloanTokenAmountis always set to the total matched loan amount in both modes or ensure the NatSpec explicitly documents this behavior so integrators can safely rely on the return value. -
L-12 Low Market Order LTV And Rate Discrepancy Unexpected Behavior Resolved
Description
For market orders,
previewBorrowunconditionally overwrites the caller supplied LTV withMIN_LTV(50%), andratewith the highest lender rate, whilematchMarketBorrowOrderonly defaults toMIN_LTVand the highest lender rate whenltvorrateare set to 0, otherwise honoring custom non‑zero value. As a result, previews can be computed with differentrate/ltvconstraints than the ones actually used at execution, leading to incorrect collateral estimates and different pools/liquidity being matched than previewed, causing confusing UX and potential failed or unexpected transactions for users and integrators that rely on previewBorrow as an accurate execution simulator.Recommendation
Update
previewBorrowso that for market orders, it resolvesltvandrateexactly likematchMarketBorrowOrder(only defaultingltv/ratewhen the input value is 0), or clearly document thatisMarketOrder==trueignores any caller-supplied rate/ltv. -
I-01 Informational Redundant Fee Adjustment In Interest Preview Superfluous Code Resolved
Description
The protocol uses
_accrueInterestfunction to update state and_previewAccrueInterestfor view-only projections, both following the same pattern - compute grossaccruedInterest, add it tototalBorrowAssetsandtotalSupplyAssets, derivemanagerFeeAmountandprotocolFeeAmountand mint fee shares. However,_previewAccrueInterestcontains an extra block.if (managerFeeAmount > 0 || protocolFeeAmount > 0) { accruedInterest -= (managerFeeAmount + protocolFeeAmount); }This modifies only a local
accruedInterestthat is never used afterward, so it has no effect on the current previewed totals and is misleading.Recommendation
Remove the unused fee subtraction in
_previewAccrueInterestfunction. -
I-02 Informational Incorrect Maker Field In OrderCanceled Event Events Resolved
Description
In
_cancelOrder, theOrderCanceledevent is emitted asemit EventsLib.OrderCanceled(isLender, msg.sender, rate, ltv, amount), even though the actual order owner is stored inentry.account. This misattributes cancellations triggered by owners/keepers/pool cleanup to the caller rather than the original maker, reducing the reliability of the event log for auditing.Recommendation
Emit
OrderCanceledwithentry.accountas themakerargument. -
I-03 Informational IPoolImplementation Interface Mismatch Informational Resolved
Description
The IPoolImplementation interface used in the Orderbook repo does not match the IPoolImplementation actually implemented by AvonPool, which may lead to broken integration for several core methods in the future.
liquidateis declared in the Orderbook interface asliquidate(address borrower, uint256 assets, uint256 shares)but AvonPool implementsliquidate(address borrower, uint256 assets, uint256 shares, uint256 minSeizedAmount, uint256 maxRepaidAsset, bytes calldata data),updateOrderbookis declared asupdateOrderbook(address newOrderbook, address newOrderbookFactory)in the Orderbook interface, while AvonPool expects an additionalbytes32 saltargument,- The Orderbook interface exposes
increaseLLTV(uint64 newLTV)whereas AvonPool usesupdateLLTVUpward(uint64 newLTV, bytes32 salt)
Recommendation
Update the Orderbook’s IPoolImplementation to match AvonPool’s signatures.
Remediation Review
8 findings · December 23 to 25, 2025-
M-01 Medium maxMint Exceeds Cap After Interest Accrual Unexpected Behavior Resolved
Description
The
maxDeposituses_previewAccrueInterest(false)to compute remaining deposit capacity from post‑accrualtotalSupplyAssets, butmaxMintconverts that capacity into shares withconvertToShares, which uses pre‑accrual pool totals. This mismatch can causemaxMintfunction to return a share amount that is not actually mintable once mint flow callsaccrueInterestand recomputes required assets, leading to unexpected reverts and breaking ERC‑4626 integrator assumptions thatmaxMintis a reliable upper bound.Recommendation
Update
maxMintto derive shares using the same preview‑accrued totals asmaxDeposit. -
M-02 Medium Transfers Can Force Referrer Assignment Logical Error Resolved
Description
The _updateReferrerOnTransfer function was updated to avoid assigning a referrer on zero-amount transfers. However, the issue still exists. If a recipient does not yet have a referrer set, and the sender has a whitelisted referrer, transferring any non-zero amount will cause the recipient to inherit the sender’s referrer.
This makes it possible for an attacker to force a referrer onto any address at minimal cost by sending a dust transfer. Once this happens, even if the recipient later attempts to set their own referrer explicitly through functions like depositWithReferrer, the forced referrer will take precedence and cannot be overridden.
Recommendation
Consider Removing referrer assignment from _updateReferrerOnTransfer; only allow referrer assignment through explicit actions (setUserReferrer or depositWithReferrer).
-
M-03 Medium De-whitelisted Referrers Can Still Claim Fees Logical Error Resolved
Description
The VaultV2 comment states that “Only whitelisted referrers can receive fee distributions”, but the implementation does not enforce this guarantee.
Whitelisting is controlled by
setIsWhitelistedReferrer, which only flips a boolean and removes the address from the_activeReferrersset. It does not clear the referrer’s stake (referrerTotalShares), does not remove their stake from the global stake counter (totalReferrerShares), and does not prevent claiming.Reward claiming is performed via
claimReferrerFees, which accrues rewards for sender and transfers shares held by the vault to an arbitrary to address. However, there is no check thatmsg.senderis currently whitelisted. Therefore, a referrer that was whitelisted in the past, accrued rewards, and was later removed from the whitelist can still callclaimReferrerFeesand receive fee shares.Also, the
_accrueReferrerusesreferrerTotalShares[referrer]as stake and does not verifyisWhitelistedReferrer[referrer]. If a referrer is de-whitelisted after having stake, that stake remains in the system and continues to earn index-based rewards unless the owner separately and correctly callsclearReferrerData.Recommendation
Require
isWhitelistedReferrer[msg.sender]inclaimReferrerFeesand, upon de-whitelisting, automatically settle the referrer’s accrual and remove their stake fromtotalReferrerSharesso they can no longer accrue or claim fee distributions. -
L-01 Low _updateOrders Can Execute During Pause Unexpected Behavior Resolved
Description
The _updateOrders function is invoked by several setter functions that are protected by a timelock. If the pool is paused at that time and a timelocked setter that calls _updateOrders is executed, _updateOrders will still run even though the pool is paused. As a result, orders are pushed back into the orderbook while the pool is in a paused state.
Recommendation
Ensure that _updateOrders does not execute while the pool is paused, or add explicit pause checks to all setter functions that invoke it. This prevents paused pools from republishing orders, especially if the timelock execution role is ever set to be permissionless.
-
L-02 Low Share Inflation Can Cause Temporary DOS DoS Acknowledged
Description
The cleanup logic that previously zeroed totalBorrowShares in _repay was removed to prevent an accounting desynchronization between the global totalBorrowShares and the sum of all user borrowShares. However, this reintroduces the initial borrow-share inflation edge case, which can be abused to temporarily block borrows.
Recommendation
Consider enforcing a minimum borrow amount, or a minimum totalBorrowAssets whenever there are open borrows, to prevent manipulation of borrow share math using small values.
-
I-01 Informational Misnamed Slippage Parameter In _borrow Resolved
Description
The
_borrowfunction applies the slippage guard correctly by ensuring that the computed borrow shares do not exceed the user-provided bound.However, the parameter is currently named
minExpected, which is misleading. The check enforces an upper bound on shares, meaning it is actually acting as a maximum acceptable amount of borrow shares rather than a minimum.Recommendation
Rename the parameter to reflect its current role as a maximum borrow share amount
-
I-02 Informational Paused Pool Quotes Can Desync State Informational Acknowledged
Description
The orderbook keeps two related states - the lender quote liquidity stored in
lenderTreeand a per-pool list of rates inpoolOrders[pool]that is primarily maintained when pools callbatchInsertOrder. These can become inconsistent in pause/refresh edge cases.When a pool pauses, it attempts to clear its quotes by calling the orderbook with empty arrays inside a
try/catchblock.try IOrderbook(s.orderBook).batchInsertOrder(rates, liquidity) {} catch { emit PoolEvents.CancelOrdersFailed(address(this)); }However,
batchInsertOrderis gated bywhenNotPaused. If the orderbook is also paused at that time, the cancellation reverts and the failure is swallowed, leaving the pool’s old quotes still present inlenderTreeand its rates still listed inpoolOrders.Once the orderbook is later unpaused (while the pool can remain paused), borrowing matches are allowed again. The matching flow first consumes/removes entries from
lenderTreevia_matchOrder, and only afterwards drops paused pools during aggregation (in_aggregatePoolData).This ordering allows a paused pool’s quotes to be removed from
lenderTreewithout any interaction with the pool, whilepoolOrders[pool]is not pruned to reflect the removals. The inconsistency persists until the pool successfully re-quotes (or an admin removes the pool), and during this window off-chain consumers ofgetPoolOrdersmay observe stale and unserviceable rates.Recommendation
Be aware of this edge case inconsistency and consider making quote cancellation/refresh resilient to orderbook pauses (e.g. allow empty
batchInsertOrdercalls to cancel existing orders even while paused). -
I-03 Informational Shares Sent Without Receive Gate Checks Logical Error Acknowledged
Description
The vault uses gates (
receiveSharesGate,sendSharesGate) to control which accounts can receive or send shares viacanReceiveShares/canSendShares, and these checks are enforced inenter,transfer, andtransferFrom. However,claimReferrerFeesandsweepOrphanedSharestransfer shares without callingcanReceiveShares.As a result, the gating policy is not consistently enforced across all share transfer paths, which can undermine assumptions and lead to shares being held by addresses that should have been blocked.
Recommendation
Verify if receiver address can receive shares in both
claimReferrerFeesandsweepOrphanedSharesfunctions. If the current behavior is intentional, explicitly document that these two functions bypass receiveShareGate/canReceiveShares, and note that if a blacklist is introduced in the future, these paths should be updated to add the gate check.
Remediation Review 2
4 findings · January 9, 2026-
M-01 Medium Re-whitelist Restores Stake With Stale Index Logical Error Resolved
Description
The contract intends to prevent unwhitelisted referrers from later receiving rewards for the period they were not whitelisted by updating their
referrerIndexOfwithout accruing rewards. However, when the user is re-whitelisted,setIsWhitelistedReferreradds the preserved stake back tototalReferrerSharesbut does not resetreferrerIndexOf[referrer]to the current referrerIndex and does not call_accrueReferrer(referrer)while the referrer is still non-whitelisted.If
referrerIndexincreases while the referrer is de-whitelisted and their checkpoint remains stale, the referrer can later be re-whitelisted and callclaimReferrerFees, causing_accrueReferrerto credit them for index growth that occurred while they were not eligible, draining referrer escrow and reducing/delaying other referrers’ claims.Recommendation
In the re-whitelisting path, update the global index and set
referrerIndexOf[referrer]to the currentreferrerIndexbefore re-adding their stake, so they start accruing only from the re-whitelisting moment. -
M-02 Medium Withdraw DoS After Referrer De-Whitelist Logical Error Resolved
Description
When the owner de-whitelists a referrer,
setIsWhitelistedReferrersubtracts the referrer’s entire stake fromtotalReferrerSharesbut leavesreferrerTotalShares[referrer]. Later, bothexit(used for withdraw and redeem) and_updateReferrerOnTransfer(used by transfer and transferFrom) can still compute a non-zero stake reduction from the reservedreferrerTotalSharesand decrementtotalReferrerShareswithout checking whether the referrer is currently whitelisted. For users tied to that de-whitelisted referrer, this decrement can underflow (sincetotalReferrerShareswas already reduced), reverting and preventing withdrawals/redeems and transfers until the referrer is re-whitelisted or the owner clears data.Recommendation
Only decrement
totalReferrerShareswhen the referrer is currently whitelisted. -
M-03 Medium totalReferrerShares Drift On Transfers Logical Error Resolved
Description
totalReferrerShares is intended to track the amount of shares currently counted toward referrer fee distribution. When a referrer is de-whitelisted, their stake is removed from totalReferrerShares, but users can still hold shares pointing to that removed referrer. In _updateReferrerOnTransfer, when shares move from a user tied to a removed referrer to a user tied to a whitelisted referrer, those shares should transition from “not counted” to “counted” and increase totalReferrerShares accordingly. However, the function only increases totalReferrerShares via the missingStake adjustment, which is typically zero in the de-whitelist case (since referrerTotalShares[fromReferrer] still exists and the subtraction does not cap). As a result, totalReferrerShares can remain too low after these transitions, drifting from the true sum of whitelisted referrer stakes. This drift can skew referrer fee allocation and referrer index accounting by using an incorrect global denominator.
Recommendation
In _updateReferrerOnTransfer, update totalReferrerShares based on whether the transferred shares were previously counted and whether they should be counted after transfer.
-
M-04 Medium clearReferrerData Skews totalReferrerShares DoS Resolved
Description
When a referrer is de-whitelisted, setIsWhitelistedReferrer(referrer, false) already removes the referrer’s entire stake by subtracting referrerTotalShares[referrer] from totalReferrerShares. Later, if the owner calls clearReferrerData(referrer), the function reads stake = referrerTotalShares[referrer] and conditionally subtracts that same stake from totalReferrerShares again whenever totalReferrerShares >= stake.
As a result, clearReferrerData can double-subtract an already-excluded stake, causing totalReferrerShares to drift downward and no longer match the sum of active referrer stake. This can lead to a underflow/DoS in referrer-related transfer and withdrawal logic.
Recommendation
Do not decrement totalReferrerShares in clearReferrerData for referrers whose stake has already been removed on de-whitelisting.
Remediation Review 3
2 findings · January 16 to 18, 2026-
M-01 Medium Legacy userAttributedShares Wipes New Stake Logical Error Acknowledged
Description
When a referrer is removed from the whitelist, the vault deletes referrerTotalShares[referrer], but it does not clear users’ userAttributedShares tied to that referrer. If the referrer is later re-whitelisted, only new deposits are added back into referrerTotalShares[referrer], while the user’s userAttributedShares still includes legacy attributed shares from before the removal.
This creates a mismatch after re-whitelisting: the referrer does not earn fees (after being re-whitelisted) from legacy userAttributedShares accumulated before/during de-whitelisting because those shares are not included in referrerTotalShares[referrer]. However, in exit(), the withdrawal reduction amount is still computed from userAttributedShares, meaning those legacy attributed shares can still drive reductions that wipe out the referrer’s newly re-accrued stake from fresh deposits.
Example:
- User A deposits and has
userAttributedShares = 200linked to Referrer A. - Referrer A is de-whitelisted →
referrerTotalShares[A]is cleared to0, but user A still hasuserAttributedShares = 200. - Referrer A is re-whitelisted and user B deposits, adding
50new attributed shares →referrerTotalShares[A] = 50. - If user A now withdraws,
exit()will reducereferrerTotalShares[A]by up to50(capped), wiping out all of Referrer A’s new stake even though the withdrawal was primarily consuming legacy attributed shares that do not earn fees.
This can cause referrers to lose future earnings capacity on legitimate new deposits due to withdrawals of older, non-counted stake.
Recommendation
Consider avoiding re-whitelisting the same referrer address after removal. If a referrer must be reinstated, require a new referrer address to prevent legacy userAttributedShares from wiping post-reinstatement stake.
- User A deposits and has
-
I-01 Informational Liquidation Can Panic With Zero bonusFactor Logical Error Acknowledged
Description
The liquidation pricing logic can hit an unhandled division by zero edge case where the computed collateral value rounds down to zero while the borrower still has non zero debt, which can occur when
position.collateral * collateralPrice < ORACLE_PRICE_SCALE (1e36).In liquidation, the code computes a collateral value in loan-token units using integer division, then derives a
coverageRatioand clamps the liquidationbonusFactorto that ratio to avoid worsening the borrower’s health. WhencoverageRatiobecomes 0,bonusFactorbecomes 0 as well, and subsequent calculations divide bybonusFactor, triggering the panic revert instead of a controlled protocol error.Recommendation
Be aware of this rare dust/low-price scenario and add a check to revert when bonusFactor is zero.
Remediation Review 4
8 findings · April 14 to 20, 2026-
H-01 High Residual Limit Orders Brick on Partial Fill Logical Error Resolved
Description
When a keeper partially fills a borrower limit order, the protocol attempts to preserve the unfilled remainder, but reinserts the residual borrower-tree entry under the wrong account. In matchLimitBorrowOrder(), the keeper executes the fill and then calls _cancelBorrowerOrder() with only the matched amount. If the order is only partially filled, _cancelBorrowerOrder() removes the old tree entry and reinserts the remainder through borrowerTree._insertOrder(false, rate, ltv, orderAmount - amount). However, _insertOrder() records entry.account = msg.sender, and in this path msg.sender is the keeper, not the borrower.
This leaves the residual order in a split-brain state. The stored order metadata remains under borrowersOrders[borrower] with reduced amount, minAmountExpected, and collateralAmount, but the live borrower-tree entry now belongs to the keeper. All later management paths still assume both pieces belong to the borrower. The borrower still has the stored order, but _cancelBorrowerOrder() scans the tree for entry.account == borrower, which now fails because the residual entry was reassigned to the keeper. As a result, the borrower cannot cancel the remainder, later keeper execution of the remainder fails through the same broken ownership path, and the borrower cannot replace the order at the same (rate, ltv) bucket because that update path also first tries to cancel the old order. In practice, once a keeper partially fills a borrower limit order, the remaining order becomes bricked.
Beyond bricking the residual order, each such partial fill also leaves behind an orphaned borrower-tree entry that normal flows cannot clean up. Since _cancelBorrowerOrder() performs a linear scan over all entries in the borrower bucket, these dead entries increase future scan costs for later cancellations, same-bucket replacements, and keeper fills. Over time, repeated partial fills at the same (rate, ltv) can therefore accumulate broken entries, increase gas costs, and worsen the borrower-bucket DOS surface.
Recommendation
If the intended behavior is to preserve residual orders after partial keeper fills, the post-fill path must reinsert the remainder under the borrower address rather than under msg.sender. More generally, borrower-tree ownership should always be sourced from the logical order owner, not from the immediate caller context. If that behavior is not intended, the protocol should instead fully remove partially filled limit orders and avoid leaving any residual order state behind.
-
M-01 Medium Partial Fills Leave Residual Orders Unbacked Logical Error Resolved
Description
When a keeper partially fills a borrower limit order, the protocol preserves a residual order in storage, but refunds collateral using the stale pre-update collateralAmount rather than the residual order’s updated backing. In matchLimitBorrowOrder(), the function snapshots collateralAmount = order.collateralAmount before calling _cancelBorrowerOrder(). During a partial fill, _cancelBorrowerOrder() reduces the stored order in place and leaves a residual order.collateralAmount proportional to the unfilled amount. However, after returning, matchLimitBorrowOrder() still computes excessCollateral = collateralAmount - totalCollateral using the original pre-update collateral value, refunds that excess back to the borrower, and then forwards totalCollateral to the pool.
This exhausts the full original escrow during the partial fill. The matched portion of collateral is pulled into the pool, and the remainder is refunded back to the borrower. Despite that, the residual order that remains in borrowersOrders[borrower] still reports a nonzero collateralAmount as if collateral were still backing the remaining order. The protocol therefore reaches a state where stored borrower-order accounting diverges from actual collateral balances held by the orderbook.
In practice, after a partial keeper fill, the orderbook can preserve a residual borrower order whose recorded collateral backing no longer matches actual escrowed funds. Any later logic that treats order.collateralAmount as live reserved collateral will be operating on stale accounting rather than real backing.
Recommendation
If the intended behavior is to reinsert residual orders after partial keeper fills, collateral refunds must be calculated from the updated residual order state rather than from the stale pre-fill collateral snapshot. The residual order should only remain in storage if the corresponding residual collateral also remains escrowed in the orderbook. If that behavior is not intended, the protocol should instead fully remove partially filled limit orders and avoid leaving any residual collateral state behind.
-
M-02 Medium Partial Fills Enforce Full-Order Min Output Logical Error Acknowledged
Description
When a keeper partially fills a borrower limit order, the protocol validates the matched slice against the full pre-fill minAmountExpected instead of against a proportional minimum for the portion actually executed. In matchLimitBorrowOrder(), the function snapshots minAmountExpected = order.minAmountExpected before matching. If the order is only partially filled, the protocol still executes the borrow for only matchedOrder.totalMatched, but later checks if (netAmount < minAmountExpected) revert ErrorsLib.InsufficientAmountReceived();. This compares the proceeds of a partial execution against the minimum output floor for the entire original order.
As a result, legitimate partial fills can revert even when the matched slice executed correctly. For example, if a borrower posts an order for 2000 units with minAmountExpected = 1800, and only 1000 units are currently fillable, the partial execution can return at most roughly 1000 units before fees. The protocol nevertheless compares that partial result against 1800 and reverts. In practice, this means partial fills only succeed when the borrower set minAmountExpected low enough to tolerate much smaller executions than the original order size.
This is also inconsistent with the protocol’s own partial-order accounting model. _cancelBorrowerOrder() already prorates minAmountExpected during partial reductions by subtracting only the matched portion’s proportional minimum from the stored residual order. The matching path, however, still enforces the original full-order minimum against the partial fill itself. The protocol therefore treats minAmountExpected as proportional in one partial-order path and absolute in another, causing otherwise valid partial executions to fail.
Recommendation
If partial limit-order fills are intended to be supported, matchLimitBorrowOrder() should validate netAmount against a proportional minimum corresponding only to matchedOrder.totalMatched, using the same scaling logic already applied in _cancelBorrowerOrder().
-
L-01 Low No Admin Cancel for Stuck Borrow Orders Warning Acknowledged
Description
The orderbook provides no privileged mechanism to cancel or clear borrower limit orders once normal user flows become impractical or unusable. Borrower-side cleanup is only available through cancelBorrowOrder(), keeper execution in matchLimitBorrowOrder(), or same-bucket replacement through insertLimitBorrowOrder(). If a borrower bucket becomes excessively bloated, stale entries accumulate, or a specific order becomes operationally stuck, there is no owner or emergency-admin function that can force-cancel the order and return the associated collateral.
This reduces the protocol’s ability to respond to griefing or stale-order conditions. Affected orders remain dependent on the same broken or impractical path, with no administrative fallback to unwind them and return collateral.
Recommendation
Consider allowing a trusted admin or emergency role to call borrower-order cancellation on a user’s behalf and refund the related collateral
-
L-02 Low RedStone maxAge allows stale data Configuration Acknowledged
Description
The RedStone oracle adapters set
maxAgeto 600000000 and compare it against block.timestamp, what gives a freshness window of roughly 19 years rather than a normal oracle heartbeat window.As a result,
getCollateralToLoanPricecan accept stale RedStone prices for borrow previews, collateral safety checks and liquidation logic.Recommendation
Replace the default
maxAgevalue with a realistic freshness window. -
L-03 Low Loan/USD freshness is not validated Configuration Acknowledged
Description
getLoanToUsdPricefunction is part of the shared oracle interface and is implemented by every oracle adapter. However, it returns the loan/USD feed answer without performing any freshness check, and_getTicksuses that value to convert pool liquidity into USD before choosing the quote granularity.As a result, stale loan/USD prices can silently drive
_getTicks()into the wrong bucket and cause pools to publish an incorrect number and distribution of quotes.Recommendation
Make every oracle adapter’s
getLoanToUsdPricevalidateupdatedAtagainstmaxAgeand revert on stale data. -
L-04 Low Underwater Liquidations Lack Incentive Warning Acknowledged
Description
The liquidation logic can make deeply underwater liquidations loss-making for liquidators. The computed liquidation bonus is clamped to the position’s collateral coverage ratio via bonusFactor = Math.min(bonusFactor, coverageRatio). When collateralValue < borrowedAssets, this ratio is below 1e18, so the effective bonus falls below par.
In this state, a liquidator can be required to repay more loan-token value than the seized collateral is worth. This clears the borrower and avoids pool bad-debt realization, but shifts the shortfall to the liquidator. Rational users are unlikely to execute these liquidations, leaving deeply underwater positions dependent on backstop intervention.
Recommendation
Consider explicitly monitoring deeply underwater positions and liquidating them through a protocol-controlled backstop or keeper process, since permissionless users are unlikely to perform liquidations that require them to absorb the shortfall. Alternatively, adjust the liquidation flow so that insufficient collateral is realized as protocol bad debt rather than being pushed onto the liquidator.
-
L-05 Low Zero repay allows users to discard dust interest Logical Error Acknowledged
Description
The pool accrual logic advances
lastUpdateeven when computed interest rounds down to zero. The publicaccrueInterestenforces a 12-hour cooldown to prevent accrual spam, but repay accepts(0, 0)inputs and calls the internal accrual path directly, bypassing that guard. An unprivileged caller can therefore trigger accruals every block, discarding each interval's fractional interest rather than letting it accumulate.uint256 accruedInterest = totalBorrowAssets.mulDiv(expFactor - PoolConstants.WAD, PoolConstants.WAD); if (accruedInterest == 0) { s.lastUpdate = currentTime; return (0, 0); }This is reachable through
repay(0, 0, onBehalf). The repay entrypoint calls accrueInterest first, then routes zero inputs into the exact-shares repay path, which completes as a no-op repayment. As a result, an unprivileged caller can repeatedly trigger accrual on short intervals and keep resettinglastUpdatewhenever the pool accrued interest for that interval is still below 1 smallest unit.The effect is limited to small pools, low-decimal assets, low rates and short intervals, where fractional interest can be repeatedly discarded and lender yield understated.
Recommendation
Reject
repay(0, 0, onBehalf)so users cannot trigger zero-effect accrual updates.
Remediation Review 5
1 finding · April 30, 2026-
M-01 Medium Stale quote can block borrow matching Logical Error Acknowledged
Description
During pool matching, the orderbook selects pools from the lender tree and then retrieve collateral value from each selected pool using
previewBorrowand finally executesdepositCollateraland process borrow for each pool.Pool quotes in the lender tree reflect the pool's state at the time they were written.
_updateOrdersuses the storeds.totalBorrowAssetsto compute capacity under the borrow cap, whilepreviewBorrowuses the projected post-accrual value. As interest accrues and the projection crosses the borrow cap,previewBorrowreturns 0 for any new borrow while the stale quote remains in the tree.if (s.borrowCap > 0 && previewPool.totalBorrowAssets + assets > s.borrowCap) return 0;When
matchMarketBorrowOrderruns, it callspreviewBorrowon each matched pool to compute required collateral. For a cap-breached pool this returns 0, which is forwarded directly intodepositCollateral, causing an unconditional revert.function _depositCollateral(PoolStorage.PoolState storage s, uint256 assets, address onBehalf) internal { if (assets == 0) revert PoolErrors.ZeroAssets();There is no skip or fallback logic, so the transaction reverts even when other whitelisted pools have available liquidity. Because the transaction reverts atomically, the stale quote is not consumed, and subsequent borrowers can repeatedly hit the same failing path even if other pools are healthy.
Recommendation
Treat
previewBorrow == 0as non-executable quote and exclude it from execution before callingdepositCollateral. Remove or refresh that stale entry in the lender tree so it cannot repeatedly block later market borrows.
No findings match.
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
