246 Club engaged Guardian to review the security of their 246 Club Core and Re-A-Token. From the 2nd of September to the 15th of September, a team of 3 auditors reviewed the source code in scope.
- Published
- Review window
- September 2 to 15, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Sonic, Plasma
- Sector
- Lending
- 0 Critical
- 1 High
- 4 Medium
- 9 Low
- 20 Informational
Scope
Overview
246 Club engaged Guardian to review the security of their 246 Club Core and Re-A-Token. From the 2nd of September to the 15th of September, a team of 3 auditors reviewed the source code in scope.
Findings 34
Main Review
29 findings-
H-01 High Interest Can Be Griefed Rounding Resolved
Description
Proof of concept: PoC
The interest mechanism is divided in 2 main steps - accrual and distribution. The accrual part is done by
BaseFacet._accrueInterest(). It computes the generated interest from the last accrual and adds it towards thepool.pendingInterest.The second step, handled by
BaseFacet.__distributePendingInterest(), distributes thispendingInterestto all active pairs for that pool. After thatpendingInterestis reset to 0. The distribution stores the accumulated interest per scaled amount since the beginning of the pair in theaccInterestPerScaledAmountvariable.uint256 weight = power.wDivDown(totalPower); restaking.accInterestPerScaledAmount + pool.pendingInterest.wMulDown(weight).wDivDown(restaking.totalScaledSupply).toUint128();This calculation is prone to precision loss. The first part -
pool.pendingInterest.wMulDown(weight)-will be in thedebttoken decimals becauseweightis inWADandwMulDownis used. The result is then divided byrestaking.totalScaledSupplywhich is inpair.assetdecimals.Imagine the scenario where we have the following pair:
- asset: WETH
- debt: USDC
Then, if the first part is
10and the second part is11e18, the end result will be10 * 1e18 / 11e18which is 0.In result, the interest for that period was accrued, but not stored in the accumulator variable
accInterestPerScaledAmount. Therefore, that interest is forever lost and cannot be claimed. An attacker can executeInterestManagementFacet.accrueAndDistributeInterest()every block to cause interest griefing.Recommendation
Consider using a higher precision for the
accInterestPerScaledAmountResolution
246 Club Team: The issue was resolved in commit 7dcd262.
-
M-01 Medium Excess Club Interest Repayment Can't Be Utilized Logical Error Acknowledged
Description
The first branch in
BorrowingFacet.repay()handles the case when the user repaying has already fully repaid their Aave debt.if (vars.positionScaledDebt = 0) { // repay to 246 pair.totalDebtInterest = pair.totalDebtInterest.zeroFloorSub(amount).toUint128(); if (data.length > 0) I246RepayCallback(msg.sender).on246Repay(amount, data); SafeTransferLib.safeTransferFrom(pairAssets.debt, msg.sender, address(this), amount); }The
_pairTotalDebt()functions reports the total debt accrued by a given pairreturn amount + pair.totalDebtInterest;Here
amountis the Aave debt andpair.totalDebtInterestis the Club246 interest.Because of the share mechanism, users that hold shares will bear a part of the accruing Aave debt of the pair, even if they don't have any active positions. When they later call
repay(), the execution flow will enter theif statementfrom above and the debt value will be repaid as club interest.When the rest of the users repay their debts, they will pay the full Aave debt because of their scaled amounts, but they will pay less club interest, because a part of it was paid by the previous user.
However, if during repayment
amount > pair.totalDebtInterest, the excess amount will be transferred in the contract, but becausezeroFloorSub()is used, this amount won't be subtracted from_pairTotalDebt(). In result, the same Aave debt has to be paid twice - users will be overcharged, but the excess amount won't be utilized.Recommendation
Consider transferring only the needed amount from the user in that
ifblock instead of the wholeamount.Resolution
246 Club Team: Acknowledged.
-
M-02 Medium Increasing Delegation From Disabled Restaking Validation Partially resolved
Description
Proof of concept: PoC
EmergencyFacet.adjustDelegation()allows managing active delegations when theaDebtbalance of the given account changes.When the debt has increased and there is a need for more power delegated, the code doesn't check if the current restaking is enabled. In result users will be able to increase the delegation provided by a disabled restaking.
This will increase the restaking's
totalScaledUsage, which will lead to unexpected increase in that restaking's_power()and further influence the interest distribution.The comment in the following snippet from
RestakingFacet.unstake()explains how thescaledAmount > restaking.totalScaledSupply - restaking.totalScaledUsagecheck is enough to prevent users from withdrawing a potential delegation power for disabled restaking.This is because it's expected the
totalScaledUsagewon't be increasing, which turns out to be a wrong assumption. This creates a discrepancy betweenunstake()andadjustDelegation().if (scaledAmount > restaking.totalScaledSupply - restaking.totalScaledUsage) { revert ErrorsLib.InsufficientLiquidity(); } { uint256 unstakeBorrowingPower = AAVE_V3_PROVIDER.borrowingPower(delegationPairAssets, amount, restaking.creditBufferRatio); // if restaking is disabled, it will be caught above.Recommendation
Consider not allowing
adjustDelegation()to increase the current delegation if the restaking is disabled.Resolution
246 Club Team: The issue was resolved in commit b4f7295.
-
M-03 Medium coverDelegationAsset Can Orphan Debt Logical Error Acknowledged
Description
EmergencyFacet.coverDelegationAssetshrinks a delegation’s restaking usage but never checks whether the pool still has outstanding debt. The function currently does:(uint256 totalPower, uint256[] memory powers) = _totalPower(delegationPairAssets.debt); _accrueInterest(pool, delegationPairAssets.debt, totalPower); _distributePendingInterest(pool, delegationPairAssets.debt, totalPower, powers); uint128 scaledAmount = AAVE_V3_PROVIDER.getATokenTransferScaledAmount(underlyingAsset, amount).toUint128(); restaking.totalScaledUsage = restaking.totalScaledUsage.zeroFloorSub(scaledAmount).toUint128();If this call targets the last restaking asset backing a debt pool,
restaking.totalScaledUsagecan become zero whilepool.totalDebtstill records an outstanding loan.Subsequent
_totalPowercalls return 0, so_accrueInterestimmediately exits with interest: 0, leaving the pool ledger stale even thoughviewerFacet.pairTotalDebtshows that debt remains.Rates and utilization are now computed against a debt figure that no longer grows and accounting/invariants break (
GLOB-02&GLOB-06invariants were triggered in the fuzzing suite).Recommendation
Before subtracting usage, compute the new value and revert if it would drop to zero while debt remains. For example:
uint128 newUsage = restaking.totalScaledUsage.zeroFloorSub(scaledAmount).toUint128(); if (newUsage = 0) { require(viewerFacet.pairTotalDebt(pairAssets) = 0, "delegation still backs debt"); } restaking.totalScaledUsage = newUsage;Resolution
246 Club Team: Acknowledged.
-
M-04 Medium Club Repayments/Liquidations Can Be Blocked DoS Resolved
Description
The repayment flow is vulnerable to the following DOS vector:
- Alice has a position with
1000Aave debt - She wants to repay a part of her position, so she calls
BorrowingFacet.repay()to repay600debt - An attacker frontruns her transaction with a direct repayment to Aave of
400. - Alice's transaction is executed and the following two calls are performed in the
elsebranch
The
connectorCallwill repay her debt and theaDebtTokenbalance of her account will become 0. Then_adjustDelegationwill setposition.delegationAsset = address(0).Even though the Aave debt has been repaid directly, Alice position still holds
position.debtShareswhich have to be repaid.The next time she calls
BorrowingFacet.repay(), the sameelsebranch runs and_adjustDelegatewill try fetching the balance of the delegation asset.However, because
delegationAssetis 0 due to the previous repayment, the transaction will revert and Alice won't be able to exit the system.She has to borrow new amount from the club in order to change her delegation asset back to a real token and repay afterwards. However, this may not be possible in some cases - if there is not enough liquidity, the borrow cap is hit, etc. Even if borrowing is possible, the victim may be liquidated by the attacker before that if the health the position allows it.
Another possible path for exploiting it is if an Aave liquidation happened - then anyone can repay a minimal amount to the club to reset the
delegationAsset.Even worse, the
LiquidationFacetlogic is similar to the repayment flow, which means liquidations can be blocked as well causing an ever increasing bad debt. And because the delegation asset is 0,EmergencyFacet.migrateDelegationAsset()cannot be called.Recommendation
function _adjustDelegation(Position storage position, address debtAsset) internal { address asset = position.delegationAsset; + if (asset = address(0)) return; }Resolution
246 Club Team: The issue was resolved in commit b94e4fe.
- Alice has a position with
-
L-01 Low Users Should Be Able To Skip Nonces Signatures Acknowledged
Description
AuthorizationFacet.setAuthorizationWithSig()allows the execution of ordered authorization transactions signed by the authorizer. For a transaction to be successful, the signed nonce must match the current onchain record for the usernonce, which increases by 1 each time a successful authorization is executed.There is no way for the user to skip nonces, which has two implications:
- If a signature with nonce
Nexpires, all signatures with noncesN + 1are blocked - the authorizer doesn't have a valid way to invalidate an already signed signature
Technically, both issues are currently solvable if the users acts properly. For the first one, they can sign a new message with the same nonce and execute it to unblock the queue, but that may not be practical.
For the second they can execute all the prior transactions until the faulty one, then sign a new empty approval with the nonce of the approval they want to cancel and execute it. This, again, is not practical because the user has to pay for the execution of all these transactions and sign a message with the same nonce.
Recommendation
Consider adding the following function to
AuthorizationFacetso users can safely skip nonces.function updateNonce(uint256 newNonce) external { if (newNonce < nonce[msg.sender]) { revert ErrorsLib.InvalidNonce(); } nonce[msg.sender] = newNonce; emit EventsLib.IncrementNonce(msg.sender, msg.sender, newNonce - 1); }Resolution
246 Club Team: Acknowledged.
- If a signature with nonce
-
L-02 Low 0 Cap Permanently Applies baseBorrowRate2 Configuration Acknowledged
Description
In
BaseFacet._borrowRate, the base rate adds the “buffer” component wheneverpairTotalDebt > cap:// if the borrow cap exceeds, it does mean the buffer allocation is on and the baseBorrowRate2 is applied uint256 baseBorrowRate = pairTotalDebt > pairParams.cap : pairParams.baseBorrowRate1 + pairParams.baseBorrowRate2 : pairParams.baseBorrowRate1;When
cap = 0(commonly used to mean “no cap”), any non‑zeropairTotalDebtmakespairTotalDebt >captrue, so the buffer component (baseBorrowRate2) is applied permanently from the first wei of debt onward.This is inconsistent with the rest of the codebase, which treats “cap enabled” as
cap > 0. For example, inBorrowingFacetthe cap check is gated byif (pairParams.cap > 0 ...), meaning a cap of zero disables cap logic.Recommendation
Align
_borrowRatewith the “cap enabled” semantics used elsewhere. Only applybaseBorrowRate2if the cap is enabled and exceeded.Resolution
246 Club Team: Acknowledged.
-
L-03 Low Some Positions Cannot Be Closed Rounding Acknowledged
Description
During
repay()andliquidate(), the borrowing account of the position will callAave.repay()to repay a given amount of assets.Looking at the
repay()function, that amount is being converted toscaledAmount(noMoreDebt, reserveCache.nextScaledVariableDebt) = IVariableDebtToken( reserveCache.variableDebtTokenAddress ).burn({ from: params.onBehalfOf, scaledAmount: paybackAmount.getVTokenBurnScaledAmount(reserveCache.nextVariableBorrowIndex), index: reserveCache.nextVariableBorrowIndex });If the
scaledAmountis0, the variable debt token will revert. Because of this, very small repayments will be reverting, potentially blockingrepay()andliquidate().Since there is no minimum amount enforced for the positions in the club, it's possible to hold a position with a small debt value and minimum collateral to support it. As time passes, the position will accrue debt, but if it's still rounding down to 0, liquidations will be impossible.
By the time the debt becomes a meaningful value, it may already have surpassed the collateral, which makes the liquidation revert forever as described in
H-01.Recommendation
Consider implementing checks for
borrow()orrepay()is called that don't allow a position to be left with a debt that rounds down to 0 scaled amount.Resolution
246 Club Team: Acknowledged.
-
L-04 Low Missing Global Reentrancy Guard Best Practices Acknowledged
Description
Several user-facing flows mutate protocol accounting and then hand control to attacker-controlled callbacks before their state transitions close. For example,
BorrowingFacet.repayreducesposition.debtShares,pair.totalDebtSharesandpool.totalDebt, then invokesI246RepayCallback(msg.sender).on246Repay(amount, data)while the pool still expects to pull amount from the repayer.LiquidationFacet.liquidatesimilarly updates borrower debt, collateral and pool totals before callingI246LiquidateCallback. Collateral entry points such assupplyCollateralcredit the user’s position and immediately execute optional callbacks and the emergency delegation helpers adjust restaking weights before finishing. None of these functions share a reentrancy latch, so a malicious borrower or liquidator can re-enter other facets while the first invocation is mid-flight.Today, each path still demands the owed token transfer or the transaction reverts, and the authorized-account checks plus Aave connector usage keep obvious drains at bay: the attacker can’t exit with extra assets as long as those post-callback transfers succeed.
However, the windows leave state momentarily inconsistent (e.g., debts already decremented, restaking usage updated) and rely on every path continuing to completion. A future refactor that inserts a new external call could become instantly exploitable.
Recommendation
Introduce a diamond-wide non-reentrancy guard. A simple pattern is a
NonReentrantFacetthat stores a single lock flag and exposes_nonReentrantBefore/_nonReentrantAfter.Wrap every external function that performs callbacks or touches user funds (borrow, repay, liquidate,
supplyCollateral, emergency delegation helpers, etc.) so the guard is held across the entire mutation, ensuring no facet can be re-entered until the original call fully settles.Resolution
246 Club Team: Acknowledged.
-
L-05 Low Refunds Are Sent To feeRecipient Logical Error Acknowledged
Description
Proof of concept: PoC
When repaying debt through the Aave connector, any amount supplied in excess of the actual variable debt is refunded by the connector to a receiver address supplied by the caller. The connector’s logic is:
function repay(address token, uint256 amount, address receiver) external { uint256 debtAmount = IERC20(PROVIDER.debtTokenAddr(token)).balanceOf(address(this)); if (amount > debtAmount) { SafeTransferLib.safeTransfer(token, receiver, amount - debtAmount); amount = debtAmount; } if (amount = 0) return; IPool pool = IPool(PROVIDER.getPool()); SafeTransferLib.safeApprove(token, address(pool), amount); pool.repay(token, amount, INTEREST_RATE_MODE, address(this)); }The problem is at both call sites (user re-pay and Aave-branch liquidation) the “receiver” argument is set to the protocol’s
feeRecipient, not the actual payer who provided the funds.As a result, whenever a payer over-supplies relative to the outstanding variable debt the connector returns the excess to
feeRecipientinstead of the payer. This silently diverts user funds to protocol fees.Recommendation
Use the payer address as the refund receiver for overpayments, not
feeRecipient. In user repay, passmsg.sender. In liquidation, pass the liquidator (alsomsg.sender).Resolution
246 Club Team: Acknowledged.
-
L-06 Low Missing Bad Debt Resolution Logical Error Acknowledged
Description
Proof of concept: PoC
The liquidation flow cannot fully resolve positions once their collateral is exhausted and the 246Club protocol lacks any write‑off or backstop mechanism to absorb the remainder. In
LiquidationFacet.liquidate, liquidation proceeds in two mutually exclusive modes. In therepaidSharepath, the contract derives the requiredseizedAmountand enforces the close factor.If the requested repayment exceeds what is allowed by close factor it reverts and if
seizedAmountexceeds available collateral, subtracting fromposition.collateralunderflows and reverts:if (uint256(position.debtShares).wMulDown(vars.closeFactor) < repaidShare) { revert ErrorsLib.ExceedMaxLiquidatableDebt(); } position.collateral - seizedAmount; // reverts if seizedAmount > collateralIn the
seizedAmountpath, the caller proposes how much collateral to seize, then the contract computes the correspondingrepaidShareand applies the same close‑factor bound. IfseizedAmountexceeds available collateral the subtraction also reverts. After a series of partial liquidations, onceposition.collateralreaches zero, both paths become impossible: any positive repayment implies seizing strictly positive collateral, which can no longer be subtracted from zero, so liquidation reverts and cannot progress further.The remaining
position.debtShares/position.scaledDebtpersist on the position. Interest continues to accrue at the pool level viaBaseFacet._accrueInterestas long aspool.totalDebt > 0andtotalPower > 0, increasing utilization and pending interest. On the restaking side, the viewer logic explicitly prevents unstaking when the pool is overutilized, for example,ViewerFacet._calculateSafeUnstakeAmountimmediately returns zero whentotalDebt > totalPower.As a result, residual unsecured debt can permanently peg utilization, cause interest to accrue on uncollectible balances and block restakers from exiting. Although
EmergencyFacetexposessupplyInterestReserve, there is no path that consumes this reserve to repay or extinguish insolvent positions. OnlyBorrowingFacet.repaycan reduce debt, which provides no incentive once collateral is gone.Because of this, insolvent accounts become permanent “zombies” that keep accruing interest and can drive utilization above available power leading to stuck restakers.
Recommendation
Consider introducing a controlled function that can zero a position whose collateral is already 0 by paying its remaining debt.
Resolution
246 Club Team: Acknowledged.
-
L-07 Low Repay Can Leave Residual Debt Dust Rounding Acknowledged
Description
Proof of concept: PoC
In the
amountpath ofBorrowingFacet.repay, the repayment amount is converted to shares using floor rounding against virtual supply constants, then those shares are burned whilepool.totalDebtis decremented by the raw user amount. This guarantees a residual remainder of dust debt inposition.debtSharesand prevents a position from being fully closed via the amount-based API.Relevant code in
repay:- If the caller supplies an amount, the code derives a share count using
toSharesDown:
if (amount > 0) share = amount.toSharesDown(_pairTotalDebt(pair, pairAssets), pair.totalDebtShares);- Then it burns those shares and reduces pool debt by the raw amount:
position.debtShares - share.toUint128(); pool.totalDebt = pool.totalDebt.zeroFloorSub(amount).toUint128();SharesMathLib.toSharesDownis:return amount.mulDivDown(totalShares + VIRTUAL_SHARES, totalAmount + VIRTUAL_AMOUNT);Both
VIRTUAL_SHARESandVIRTUAL_AMOUNTare non-zero. This makes the conversion intentionally conservative. For any nominal “full repayment” amount A that a user computes in amount space, the computedshare = floor(A · (T+VS)/(D+VA))is biased downward versus the exact proportional value. As a result, even when A equals the user’s entire borrow converted via amounts, share will typically be strictly less than the user’s outstandingposition.debtShares.The function then:
- Burns fewer shares than the position holds (leaving
position.debtShares > 0). - Decreases
pool.totalDebtby the full amount provided by the user (not by the effective amount that those burned shares represent under the share model).
This leaves a non-zero residual of
position.debtShares(and corresponding dust debt in the pair) that continues to accrue interest. The user sees that their “full” amount repayment did not actually close the position. The dust remains until they explicitly repay by shares. Over time, the dust accrues interest, can continue to affect health checks and can block full collateral withdrawal until a share-based cleanup is performed.The option of increasing the
amountpassed as parameter to the repay function is not valid as that would cause a revert by underflow here:position.debtShares - share.toUint128();Recommendation
Provide an explicit “repay all” path that clears the position by shares, or make the amount path upgrade itself to a share-close when the supplied
amountis sufficient.Two viable patterns:
- Add a convenience that callers can use to deterministically zero debt:
previewRepayAllAmount(pairAssets, onBehalf): returnstoAmountUp(position.debtShares, pairTotalDebt, totalDebtShares).repayAll(pairAssets, onBehalf): internally routes torepay(pairAssets, 0, position.debtShares, onBehalf, "").- In
repay(amount), after computing share withtoSharesDown, checkif amount > toAmountUp(position.debtShares, pairTotalDebt, totalDebtShares). If true, set
share = position.debtSharesand proceed on the share-based path, ensuring the position is completely cleared without residual debt.Resolution
246 Club Team: Acknowledged .
- If the caller supplies an amount, the code derives a share count using
-
L-08 Low Aave Repayments Desync Internal Scaled Debt Logical Error Acknowledged
Description
Proof of concept: PoC
The protocol’s debt accounting assumes that all reductions of Aave variable debt occur through
BorrowingFacet.repay/LiquidationFacet.liquidate. If a third party (or the borrower) repays directly on Aave to the position’sAccount, the actual Aave vDebt goes down but the protocol’s internal scaled debt stays unchanged:- Per‑pair debt is derived from the internal
pair.totalScaledDebt(pluspair.totalDebtInterest). - Interest accrual and utilization use that value and utilization also uses
pool.totalDebt, which only changes inside
protocol flows.
A direct Aave repay lowers the real Aave vDebt, but does not decrease
position.scaledDebt,pair.totalScaledDebt, orpool.totalDebt. Until the next in‑protocol touch.- The protocol over‑estimates debt and over‑accrues interest/fees (higher
borrowUsageRatio, higherborrowRate,
larger
interestAccrual).- Users may be blocked from borrowing earlier than warranted (artificially high utilization).
The desynchronization creates a second, more acute issue when someone later repays through the protocol.
BorrowingFacet.repaycomputesamount/shareagainst_pairTotalDebt(...)(which reads the stalepair.totalScaledDebt) and then sends that fullamountto theAccountand instructs the connector to repay AaveThe Aave connector then measures the actual Aave debt and refunds any excess to the
receiverpassed in the calldata which is hard‑coded asfeeRecipient.Because the protocol over‑estimated
_pairTotalDebt, it frequently sends more than Aave will accept. The resulting over‑payment is refunded tofeeRecipientinstead of the payer. The same pattern is present inLiquidationFacet.liquidate, which also passesfeeRecipientas the refund receiver when constructing the connector calldata.The impact of this issue is that the incorrect debt/interest/utilization accounting persists until the position is fully closed. Also, subsequent protocol‑level repays/liquidations will over‑charge users and divert the surplus to
feeRecipient.Recommendation
Consider implementing a public/admin “sync” function to realign internal scaled debt with Aave for a position/pair in case off‑protocol repayments occur and consider calling it inside emergency/maintenance flows. Any refund on any over‑repay should be sent to the payer, not
feeRecipient.Resolution
246 Club Team: Acknowledged.
- Per‑pair debt is derived from the internal
-
L-09 Low Disabling An In-use Restaking Asset Logical Error Acknowledged
Description
In
ConfiguratorFacet.enableRestakingthe diamond allows a restaking market to be toggled off even when borrowers still delegate it. Disabling setsrestaking.enabled = falseafter the usual accounting (_accrueInterest/_distributePendingInterest).Once enabled is
false,_powerin theBaseFacetno longer values the asset by its total restaked supply but instead uses only the delegated amount (already haircut bycreditBufferRatio)._totalPowerthen reports a sharply reduced pool power whilePool.totalDebtremains unchanged.During multiple fuzz runs we observed utilization
totalDebt * 1e18 / totalPowerexceed1.05e18, violating the protocol’s requirement that utilization stay at or below 105%.The root cause is that the contract permits disabling a restaking asset that is still underwriting outstanding debt, so the backing power suddenly collapses while the debt stays outstanding.
Recommendation
Consider performing this step, and right after, if possible atomically, migrate borrowers to another enabled restaking asset.
Resolution
246 Club Team: Acknowledged.
-
I-01 Informational Rounding Mismatch Rounding Acknowledged
Description
The pool-level debt cache (
pool.totalDebt) is updated using caller-facing “amounts”, while per‑pair debt is tracked in Aave’s scaled units and converted via the variable index.This mixed basis causes
pool.totalDebtto temporarily diverge from the true sum of pairs and inrepay/liquidationbranches it can be driven to zero while residual pair debt remains.When that happens, the next accrual step treats the pool as having no debt and advances time without accruing interest, undercharging borrowers and underpaying restakers/fees for the elapsed interval. Pair debt is computed with Aave’s index and includes the protocol interest pot.
The
repay/liquidatepaths reduce the pool cache by the full user input (principal + interest portion), regardless of what Aave actually burns under its rounding rules.Because
amountis not guaranteed to equal the Aave principal delta (due to scaled rounding), the pool cache can undershoot the sum of pairs immediately after arepay/liquidation.A test showed that after liquidating the only position of a pair,
pool.totalDebt = 0whilepairTotalDebt> 0andAave vDebt > 0. The next_accrueInterestcall sees zero usage, advanceslastUpdatedand accrues 0 for the whole interval despite outstanding pair debt.Health checks and share math are unaffected because they use pair-level values. The liquidity guard if (
pool.totalDebt > totalPower) becomes slightly more lenient during the drift window.Recommendation
Consider updating
pool.totalDebtusing the exact Aave principal delta, not the caller “amount”.Resolution
246 Club Team: Acknowledged.
-
I-02 Informational Pair Cap Hard-cap Placeholders Configuration Acknowledged
Description
Multiple production-targeted pair deployment/update scripts configure pairs with a placeholder hard cap of 1 base unit and keep them enabled while
baseBorrowRate2is zero. This configuration makes the market unborrowable and causes borrows to revert once total pair debt exceeds 1 unit of the debt token.Examples:
script/deploy/chain/mainnet/pair/Re7WethWeth.s.solscript/deploy/chain/mainnet/pair/GauntletWethCoreWeth.s.solscript/deploy/chain/mainnet/pair/FalconUsdcGho.s.sol
Batch update scripts broadcast every .sol in the directory with no allowlisting or sanity gates:
script/deploy/updateAllPairs.sh
On-chain cap enforcement in
BorrowingFacet.borrow:vars.pairTotalDebtincludes the newly-added scaled debt and interest. Withcap=1andbaseBorrowRate2=0, any borrow that takes the pair over 1 base unit reverts.Because cap is denominated in debt token units, cap=1 means 1 wei for 18-decimals (e.g., WETH) or 1 “micro-unit” for 6-decimals (e.g., USDC), which is effectively zero capacity.
The scripts simultaneously set
enabled = true, presenting these markets as active while they cannot be borrowed.Therefore, if these placeholder configs are ever pushed to production via
updateAllPairs.sh, they will freeze borrowing on affected markets (all borrow attempts revert), despite the pair being enabled.Recommendation
Consider revisiting the config values of the listed pairs above.
Resolution
246 Club Team: Acknowledged.
-
I-03 Informational Ownership Is Transferred At Once Best Practices Acknowledged
Description
OwnershipFacet.transferOwnership()changes the owner of the Diamond in 1 step. It's typically advised to have this feature executed in 2 steps - the first sets the new owner as a pending owner and the second allows the pending owner to accept the ownership.This way the risk of giving the ownership to an invalid or a malicious address is decreased.
Recommendation
Consider implementing the 2 step ownership transfer
Resolution
246 Club Team: Acknowledged.
-
I-04 Informational EOAs Can Be Set As Facets Validation Acknowledged
Description
Before adding facet via
DiamondLib.addFacet()or calling it byDiamondLib.enforceHasContractCode(), theenforceHasContractCode()function will revert if the target'scodesizeis 0.Since
EIP7702EOAs can set a delegate contract which will be executed once their address is called. EOAs that use this feature havecodesize = 23.This will satisfy the performed check and allow an EOA to be added as a facet or called for initialization.
Recommendation
Modify the check in
enforceHasContractCode()and revert if the first three bytes of the code of the target are0xef0100- these are reserved forEIP7702.Resolution
246 Club Team: Acknowledged.
-
I-05 Informational DiamondLib Loops Are Not Optimized Gas Optimization Acknowledged
Description
The for loops in
DiamondLib.addFunctions()andDiamondLib.replaceFunctions()update theselectorIndexin the loop body.Because of this, the Solidity compiler will not optimize them by applying
uncheckedmath when they are incremented.Recommendation
To benefit from the implicit gas optimization, move the
++selectorIndexto the iteration part of the loop.• for (uint256 selectorIndex; selectorIndex < _functionSelectors.length;) { + for (uint256 selectorIndex; selectorIndex < _functionSelectors.length; ++selectorIndex) { ... • ++selectorIndex; }Resolution
246 Club Team: Acknowledged.
-
I-06 Informational initializeDiamondCut() Can Invoke Non-facets Validation Acknowledged
Description
The call to
initializeDiamondCut()inDiamondLib.diamondCut()will execute the desired calldata on theinitaddress. It's not guaranteed that this address is a valid facet, it can be any EVM address.While this may be desirable since it allows having different contracts, separate from the facets, which handle the initialization, it can also lead to unexpected behaviors if a wrong address is provided.
Recommendation
Either acknowledge and be careful with the addresses provided, or enforce that
initis a facet in the code.Resolution
246 Club Team: Acknowledged.
-
I-07 Informational Initializing Multiple Facets Is Not Supported Configuration Acknowledged
Description
When
DiamondLib.diamondCut()is called, each cut can handle different facets, but theinitializeDiamondCut()calls only 1 of them.The rest of the facets will have to be manually initialized which increases the risk of an adversary taking control over them.
Recommendation
Consider reworking the
initializeDiamondCut()function so it supports calls to multiple facets.Resolution
246 Club Team: Acknowledged.
-
I-08 Informational Potential Division By Zero In _borrowRate Validation Resolved
Description
In
BaseFacet._borrowRate, the above-optimal branch computes the slope-2 contribution by dividing by (WAD- optimalUsageRatio):
if (borrowUsageRatio < optimalUsageRatio) { // Calculate rate using slope1 rate = baseBorrowRate + borrowUsageRatio.mulDivDown(pairParams.slope1, optimalUsageRatio); } else { // Calculate rate using slope2 uint256 excessUsage = borrowUsageRatio - optimalUsageRatio; rate = baseBorrowRate + pairParams.slope1 + excessUsage.mulDivDown(pairParams.slope2, WAD - optimalUsageRatio); }If the pool is configured with
optimalUsageRatio = WAD(i.e., 100%), the denominator of the above-optimal branch becomes zero.While borrows are gated by
pool.totalDebt <= totalPower, the utilization used in_borrowRatecomes fromborrowUsageRatio = pool.totalDebt.wDivDown(totalPower), wheretotalPoweris recomputed on the fly and can drop belowpool.totalDebtwithout a new borrow (e.g., after disabling a restaking asset, migration/usage shifts or other supply/usage changes).In those situations,
borrowUsageRatiocan exceedWAD, the code takes the above-optimal path and division by (WAD - optimalUsageRatio) withoptimalUsageRatio = WADtriggers a division-by-zero revert.Because
_borrowRateis called inside_accrueInterest, this revert DoS-es interest accrual (and any flow that accrues interest), blocking protocol operations until the configuration is corrected.Recommendation
Disallow
optimalUsageRatio = WADat configuration time and assert the invariant defensively in_borrowRate. InConfiguratorFacet.setPoolOptimalUsageRatioadd the following require check:require(newOptimalUsageRatio < WAD, ErrorsLib.InvalidInputRatio());Resolution
246 Club Team: The issue was resolved in commit 8cc59ba.
-
I-09 Informational Missing Explicit Collateral Balance Check Validation Acknowledged
Description
In
CollateralManagementFacet.withdrawCollateral, the function subtracts the requested amount fromposition.collateralwithout first checking that the position has enough collateral:position.collateral - amount;If
amount > position.collateral, Solidity 0.8’s checked arithmetic triggers a generic underflow “panic” revert before your custom errorErrorsLib.InsufficientCollateral()is reached.This yields a poor DX (ambiguous revert reason) and makes it harder to diagnose whether the failure is from “not enough collateral balance” vs. failing the post-withdraw health check.
Recommendation
Add an explicit balance check before the subtraction and revert with a descriptive custom error. Consider a new, precise error (e.g.,
InsufficientCollateralBalance) to distinguish from the risk-based_isHealthyfailure (InsufficientCollateral).For example:
if (position.collateral < amount) revert ErrorsLib.InsufficientCollateral(); position.collateral - amount;Resolution
246 Club Team: Acknowledged.
-
I-10 Informational Potential DOS Due To Unbounded Accrual Loops DoS Partially resolved
Description
The protocol couples interest accrual and power computation to many state‑changing flows and performs unbounded iteration over pool data structures, creating a gas‑exhaustion vector that can revert borrow, repay, liquidation, restake/unstake and configuration operations as the pool grows.
In
BaseFacet._accrueInterestthe function iterates over every pair in the pool to compute per‑pair interest and update accounting.This is
O(P)with P = number of pairs in the pool. The caller always passes a freshtotalPowerthat is computed viaBaseFacet._totalPower, which iterates over all restaking assets in the pool.This is
O(A)with A = number of restaked assets and_powerincludes external reads to Aave indices and further arithmetic per asset.These two loops run on hot paths:
BorrowingFacet.borrow/repay,LiquidationFacet.liquidate,RestakingFacet.restake/unstake, and several Configurator/Emergency functions call_accrueInterest(and often_totalPower) before proceeding.There are no on‑chain bounds on
pool.pairIdListorpool.assetListsizes. As they grow, the gas cost scales roughly linearly and can exceed block gas limits, making essential operations revert out‑of‑gas.Recommendation
Introduce hard upper bounds on the number of pairs and restaked assets per pool at the configurator level.
Resolution
246 Club Team: The issue was resolved in commit ede7de2.
-
I-11 Informational Trapped Collateral In Paused Pool If Liquidated Informational Partially resolved
Description
CollateralManagementFacet.supplyCollateral()allows supplying collateral to a paused pool. This lets the users improve the health of their positions.// do not check if the pool or pair are enabled or not // user can make a position healthy by supplying more collateral even though the pool or pair are disabledIf a
liquidationcall happens before thesupply, the collateral will be successfully added, but any attempts to withdraw it will be failing since the pool is paused.This will trap the collateral in the contract until the pool is unpaused, even if that collateral doesn't back any debt (if full liquidation happened).
Recommendation
Inform the users about this behavior. They can use the
on246SupplyCollateral()to check if they were liquidated.Resolution
246 Club Team: The issue was resolved in commit 1e33b82.
-
I-12 Informational Utilization Can Exceed 100% Informational Acknowledged
Description
The club utilization for a given pool is
totalDebt / totalPower, wheretotalPoweris the maximum borrowable amount from Aave for the available restaking amount, andtotalDebtincludes both the Aave debt and the club interest.Because of this, it's possible to have
totalDebt > totalPower, especially when the assets borrowed from Aave are close to the total power.Recommendation
Acknowledge the issue and configure the pools appropriately to incentivize repayments when
utilization > 100%Resolution
246 Club Team: Acknowledged.
-
I-13 Informational convertToRedeem Doesn't Adjust For Max Amount Unexpected Behavior Acknowledged
Description
PendleReATokenAdapter.convertToRedeem()withdraws assets from Aave and returns how many were received by callingpreviewConvertATokenToUnderlying(). That function returns the unchanged amount being withdrawn.However, Aave supports max withdrawals. Users can pass
type(uint256).maxand it will be treated as their full balance.Because this is not accounted for in
previewConvertATokenToUnderlying(), the returned result will be2^256 - 1.Recommendation
There is no need to call
previewConvertATokenToUnderlying()at all. You canAAVE_V3_POOL.withdraw()returns the withdrawn amount, use that instead.Resolution
246 Club Team: Acknowledged.
-
I-14 Informational Reserve Flags And Isolation Mode Are Ignored Validation Acknowledged
Description
The helper
AaveV3Lib.isRestakablefunction currently returns true solely when the reserve’s LTV is greater than zero. This is too permissive. It ignores critical reserve state flags such as active/frozen/paused and isolation/silo constraints exposed by Aave’sReserveConfiguration.A reserve can have
LTV > 0while being frozen or paused (e.g., during risk events or deprecations), or while being in isolation mode that imposes significant restrictions.In these cases, treating the asset as “restakable” allows creation of restaking pairs and acceptance of user restakes that may later stall or revert during delegation, borrowing or migrations.
The restaking and migration flows rely on
isRestakableto greenlight an asset, so approving assets that are paused/frozen or under isolation can lead to operational inconsistencies. For example, the asset can:- Be included in
assetListand contribute power/weight for interest distribution and capacity even
while the reserve is paused/frozen.
- Allow restakes to proceed while later delegation/borrowing paths fail or are blocked by Aave
reserve constraints.
- Require emergency cleanups or migration when the reserve can not be used, stranding user
aTokenstemporarily and degrading UX.Recommendation
Update the
isRestakablefunction to require bothLTV > 0and healthy reserve flags and explicitly reject isolation-mode assets if unsupported in your protocol.Resolution
246 Club Team: Acknowledged.
- Be included in
-
I-15 Informational Aave Liquidations Cause Restaker Losses Unexpected Behavior Partially resolved
Description
Typically, the club liquidation parameters will be configured in a way that club liquidation happen before Aave liquidations.
However, if the price of a delegation asset drops and results in unhealthy position at Aave, this will not be reflected at the club level because there the health is determined only by their collateral and the debt.
It's expected that
EmergencyFacet.adjustDelegation()will be called frequently to not allow Aave liquidations, but there is no guarantee this can always happen.Whenever such liquidation happens, the restaker will have lost all their assets and the borrower can just repay their current debt to withdraw their collateral, resulting in a loss for the restakers.
Furthermore, if the protocol don't call
EmergencyFacet.coverDelegationAsset(), therestaking.totalScaledSupplyandrestaking.totalScaledUsagevalues will be left inflated.Recommendation
Ensure the restakers are aware of this risk and they perform frequent calls to
adjustDelegation().Resolution
246 Club Team: The issue was resolved in commit 57978f9.
Remediation Review
5 findings-
I-01 Informational Diamond Missing IERC165 Flag Best Practices Acknowledged
Description
DiamondLoupeFacet.supportsInterfacesimply returnsds.supportedInterfaces[_interfaceId], yet no constructor, initializer, or facet ever writes to that mapping inDiamondLib.DiamondStorage.As deployed, the diamond therefore reports false for
type(IERC165).interfaceId (0x01ffc9a7)and every other interface, violatingERC-165and breaking external contracts that relies on standard introspection.Contracts that expects
ERC-165compliance will treat the diamond as non-standard.Recommendation
During the initial diamond cut or initializer, explicitly set the
IERC165bit, for example:DiamondLib.diamondStorage().supportedInterfaces[type(IERC165).interfaceId] = true;and register any additional interface IDs that the diamond intends to advertise.
Resolution
246 Club Team: Acknowledged.
-
I-02 Informational Connector Can Break On Non-Standard Approvals Best Practices Acknowledged
Description
AaveV3Connector.repaygrants allowance to the Aave pool viaSafeTransferLib.safeApprove(token,address(pool), amount).Solady’s
safeApprovejust forwards the directapprove(amount)call without first clearing the previous allowance. For tokens that enforce the zero-first approval pattern (e.g. USDT), any attempt to change an existing non-zero allowance to another non-zero value reverts.After the first repayment, if the entire allowance was not consumed, the next approval attempt can revert, blocking borrowers from repaying and exposing them to liquidation risk.
function repay(address token, uint256 amount, address receiver) external { uint256 debtAmount = IERC20(PROVIDER.debtTokenAddr(token)).balanceOf(address(this)); if (amount > debtAmount) { SafeTransferLib.safeTransfer(token, receiver, amount - debtAmount); amount = debtAmount; } if (amount = 0) return; IPool pool = IPool(PROVIDER.getPool()); SafeTransferLib.safeApprove(token, address(pool), amount); pool.repay(token, amount, INTEREST_RATE_MODE, address(this)); }Recommendation
Adopt an approval flow that supports zero first semantics by switching to
SafeTransferLib.safeApproveWithRetry.Resolution
246 Club Team: Acknowledged.
-
I-03 Informational Diamond Accepts Unrecoverable ETH Best Practices Acknowledged
Description
Diamond246leaves bothfallbackandreceive()payable, so anyone can push plain ETH into the proxy. The current facet set exposes no function to sweep native assets, meaning any ETH sent this way is stuck until governance cuts in a new facet.fallback() external payable { DiamondLib.DiamondStorage storage ds; bytes32 position = DiamondLib.DIAMOND_STORAGE_POSITION; assembly { ds.slot = position } address facet = ds.selectorToFacetAndPosition[msg.sig].facetAddress; if (facet = address(0)) { revert DiamondLib.FunctionDoesNotExist(); } assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } receive() external payable {}Recommendation
Disallow raw ETH by making the entry points non-payable, automatically wrap inbound ETH into WETH if that user flow is intentional, or add an admin-only sweep in a facet so governance can recover accidental deposits.
Resolution
246 Club Team: Acknowledged.
-
I-04 Informational ERC20 May Not Support Decimals() Best Practices Acknowledged
Description
AccInterestLib.isPrecisionRequired()callsdecimals()on theassetanddebttokens unconditionally, but the function is not part of theERC20standard, so it's optional. If working with such tokens, the call todecimals()will cause a revert.Recommendation
Acknowledge if such tokens are not going to be supported, otherwise wrap the call in a
try/catchand use default decimals if it fails.Resolution
246 Club Team: Acknowledged.
-
I-05 Informational ACC_INTEREST_PRECISION Can Be Improved Best Practices Acknowledged
Description
The calculation for
interestPerScaledAmountis(pendingInterest.wMulDown(weight) * ACC_INTEREST_PRECISION).wDivDown(totalScaledSupply)Or
pendingInterest * weight / 1e18 * ACC_INTEREST_PRECISION * 1e18 / totalScaledSupplyThere is a division before multiplication resulting in a precision loss. The expression is first divided by
1e18and then multiplied by the same number, which means they would be cancelled out. The value of the expression is equivalent to the followingpendingInterest * weight * ACC_INTEREST_PRECISION / totalScaledSupplyThe same is true for the case without precision.
pendingInterest.wMulDown(weight).wDivDown(totalScaledSupply);Can be rewritten to
pendingInterest * weight / totalSupplyAlso note that it's possible for a precision loss to happen for tokens with the same decimals as well if the amount and weight are small. For example
amount = 5andweight =0.1e17`. This would also round down to 0.If you want to further prevent this issue, you can either:
- skip resetting the
pool.pendingInterestin_distributePendingInterestto 0 if NONE of the pairs accrued
interest
- or recalculate the added
interestPadafter each pair accrual and subtract it frompool.pendingInterest
instead of resetting it to 0.
Recommendation
Consider whether each of the proposed optimization is desirable and implement them if needed.
Resolution
246 Club Team: Acknowledged.
- skip resetting the
No findings match.
More from 246 Club
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.
