Guardian's review of RAAC Core Contracts for raac.io, published October 2026. The report records 30 findings across 2 review rounds, including 1 medium and 14 low.
- Published
- Review window
- October 2 to 28, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- 0 Critical
- 0 High
- 1 Medium
- 14 Low
- 15 Informational
Scope
20 files in scope · 2,590 nSLOC
| File | nSLOC | Lines |
|---|---|---|
contracts/core/oracles/CrvUSDToUSDOracle.sol | 119 | 266 |
contracts/core/oracles/PmUSDToUSDOracle.sol | 5 | 13 |
contracts/core/pools/LendingPool/ERC20AssetAdapter.sol | 109 | 181 |
contracts/core/pools/LendingPool/ERC721AssetAdapter.sol | 138 | 215 |
contracts/core/pools/LendingPool/LendingPool.sol | 594 | 1145 |
contracts/core/pools/LendingPool/LendingPoolStorage.sol | 45 | 87 |
contracts/core/pools/LendingPool/LiquidationProxy.sol | 77 | 152 |
contracts/core/pools/LendingPool/VaultProxy.sol | 198 | 355 |
contracts/core/pools/StabilityPool/LiquidationStrategyProxy.sol | 93 | 175 |
contracts/core/pools/StabilityPool/LiquidationSwap.sol | 75 | 140 |
contracts/core/pools/StabilityPool/StabilityPool.sol | 249 | 483 |
contracts/core/pools/StabilityPool/StabilityPoolStorage.sol | 40 | 58 |
contracts/core/tokens/DebtToken.sol | 103 | 232 |
contracts/core/tokens/DEToken.sol | 148 | 315 |
contracts/core/tokens/RToken.sol | 166 | 383 |
contracts/libraries/math/PercentageMath.sol | 29 | 62 |
contracts/libraries/math/WadRayMath.sol | 97 | 218 |
contracts/libraries/pools/LendingPoolFeeLibrary.sol | 45 | 81 |
contracts/libraries/pools/ReserveLibrary.sol | 246 | 513 |
contracts/libraries/utils/StringUtils.sol | 14 | 29 |
Findings 30
Main Review
29 findings · October 2 to 16, 2026-
M-01 Medium LendingPool.pause() blocks StabilityPool.liquidateBorrower via updateState DoS Resolved
Description
The only production settlement path is
StabilityPool.liquidateBorrower→LiquidationStrategyProxy.liquidateBorrower. That function always callsLendingPool.updateState()before it finalizes, withdraws RToken, or redeposits leftover reserve asset.updateState()iswhenNotPaused. A LendingPool pause therefore reverts the strategy before any settlement.finalizeLiquidation()has no pause guard, anddeposit()/withdraw()explicitly skip pause whenmsg.senderis the Stability Pool so liquidations can finish. Those exemptions never run.There is no other live route. External
finalizeLiquidation()calls revertonlyStabilityPool. Direct strategy calls revertonlyProxy. The Stability Pool only reaches finalize through this function.The public checkpoint is also unnecessary for settlement.
getPositionScaledDebtalready uses a view index, andLiquidationProxy.finalizeLiquidationcallsupdateReserveStatebefore it burns debt.Alice is flagged and the grace period has ended. An emergency pause freezes user deposit, borrow, repay, and
initiateLiquidation. The manager then callsliquidateBorrower.updateState()revertsEnforcedPause. Alice's collateral stays on the adapter, her debt stays open, and the backstop cannot close the hole until the pool is unpaused, which also turns user flows back on.// Update lending pool state before liquidation lendingPool.updateState();PoC:
test/unit/core/pools/StabilityPool/StabilityPool.test.js(H-01: LendingPool pause reverts liquidateBorrower on updateState).Recommendation
Do not gate settlement on the public paused
updateState(). LetfinalizeLiquidationrefresh reserve state itself, or expose an internal accrue that the Stability Pool can call while the pool is paused, so the existing deposit and withdraw exemptions can complete a liquidation. -
L-01 Low repayOnBehalf can grief a rescue Frontrunning Acknowledged
Description
Anyone can call
repayOnBehalffor at least 1% of a position's debt._repaydoes not cap an ordinary amount to remaining debt. Onlytype(uint256).maxis replaced with the live debt. Any other amount goes toDebtToken.burn, which revertsInsufficientBalancewhen it is larger than remaining debt.A borrower who quoted an exact repay can therefore be front run. After the 1% cut, that quoted amount is too large and the rescue reverts. If this happens at the end of insured grace, the next repay is past the deadline and reverts
GracePeriodExpired. The Stability Pool can then finalize and take the collateral.uint256 actualRepayAmount = (amount == type(uint256).max) ? positionScaledDebt : amount;Recommendation
Be aware of and document this behavior. A 1%
repayOnBehalfcan void a quoted exact repay at the end of grace, after which the borrower cannot retry and the position can be liquidated. -
L-02 Low Permissionless checkpoints compound the linear supply index and drain reserve spread economic-accounting Resolved
Description
updateReserveInterests applies a linear factor to the already-indexed liquidityIndex and then resets lastUpdateTimestamp. Because updateState is permissionless, callers choose the number of checkpoints in an elapsed interval; repeated daily checkpoints therefore compound a rate documented and implemented as linear. The debt index remains path-independent compound, so this is not a symmetric borrower/supplier calculation.
Impact: The PoC passes against the real LendingPool stack: at 100% utilization, one annual checkpoint pays 75,267 crvUSD interest on 100,000 principal, while 365 permissionless daily checkpoints pay 112,101.56 crvUSD. The extra 36,834.56 is realized on withdrawal and leaves only about 190.56 crvUSD of cash after the pending fee versus about 42,435.51 in the control. Thus a supplier/keeper can make liabilities and retained spread depend on call frequency and can consume the buffer intended for protocol fees and bad debt.
Recommendation
Aave V3 uses the same split. Its supply index is a linear factor multiplied into the stored index, and its borrow index is compound. However, Aave’s rates do not sit on a flat half-of-prime and then step up to an 83% rate ceiling.
Consider documenting this behavior instead of refactoring the interest rate calculations. Monitor utilization rates constantly, and make sure the manage the protocol caps to avoid a high utilization rate over a long period of time.
Additionally, make sure the protocol docs describes the real behavior of the supply rate, to avoid confusion, as the
ReserveLibrary.mdnote statesInterest calculations use different methods for deposits (linear) and borrows (compound). -
L-03 Low Stale NFT appraisals are accepted on the live iRAAC collateral/NAV path oracle-staleness/economic-accounting Resolved
Description
RAACNFT.mint enforces a 3600-second freshness rule, but the production RWAVault path receives the same (price,timestamp) from RAACNFTVaultAdapterOracle and discards the timestamp in ERC721VaultAdapter/RAACNFTVaultAdapterV2. The vault therefore accepts an NFT with an expired appraisal, adds its obsolete value to nftsValue, and mints iRAAC against it; RWAIndexTokenOracle then values those shares through the LendingPool's live ERC20 adapter with no age check. The PoC confirms the stale timestamp on both house prices and the live vault oracle, shows the mint path reverting while vault deposit succeeds, and demonstrates subsequent borrowing and NAV dilution of an existing holder when the quote is eventually marked down.
Impact: An oracle outage or delayed appraisal allows a stale-high NFT quote to over-issue iRAAC and make the over-issued shares eligible for borrowing. The cached NAV remains inflated until an external price push; when corrected, pre-existing iRAAC holders lose redeemable value and the lending position becomes undercollateralized, with repeatability across NFTs. The direct ERC721 adapter has the same stale-price omission, but the accepted impact is primarily the deployed iRAAC route.
Recommendation
A first approach will suggest to keep the price updated with the same freshness as the NFT minting. However, we recommend to monitor the house prices closely, and trigger updates when there is a significant deviation with the on chain price, avoiding step wise jumps in LTV.
-
L-04 Low StabilityPool liquidation requires idle cash, blocking bad-debt resolution at high utilization denial-of-service Resolved
Description
LiquidationStrategyProxy first computes the shortfall in RTokens, then calls LendingPool.withdraw() to redeem those RTokens for crvUSD before transferring the debt amount back to RToken. LendingPool.withdraw() unconditionally requires equivalent idle RToken cash (or withdrawable vault liquidity). Thus a reserve with ordinary high utilization can hold sufficient RToken backstop while finalization reverts atomically for insufficient pool liquidity; the no-vault path has the same requirement. The submitted five-test real-stack PoC passes, including a control liquidation with cash, rollback on the stressed liquidation, and the independent DEToken withdrawal path that can reduce the RToken backstop below the required shortfall.
Impact: Underwater positions remain latched and their debt continues accruing while managers cannot finalize them; suppliers also cannot redeem against the same unavailable cash. A borrower can temporarily consume the idle buffer before a manager transaction, and an SP depositor can permissionlessly exit through the timelock, causing repeated finalization failures. This can keep bad debt unresolved and leave the reserve/backstop insolvent during stress. This is distinct from the accounting loss after a successful liquidation: the failure occurs before the RToken redemption and does not depend on a DEToken haircut.
Recommendation
Burn the stability pool’s rToken for the gap, and do not withdraw pmUSD to do it. The liquidation then no longer depends on idle cash. If the stability pool does not hold enough rToken, either keep the liquidation reverting, or complete it and leave the uncovered amount as a shortfall for rToken holders. The treasury can refill that shortfall by transferring pmUSD to the rToken contract without minting new rToken, and can restore the stability pool by transferring rToken to it directly.
Resolution
raac.io Team: We've updated the liquidations page to cover this: https://docs.raac.io/stability-pool-liquidations/ (see "Covering a shortfall")
-
L-05 Low Duplicate same-token adapters alias positions and let an attacker erase debt while draining the StabilityPool access-control Resolved
Description
If a trusted adapter migration registers a second ERC20AssetAdapter for the same collateral token without removing the first, both adapters share getPositionKey() = keccak256(token) while maintaining independent collateral balances. LendingPool.registerAdapter only rejects duplicate adapter addresses and has no token-uniqueness guard. An unprivileged attacker can deposit dust through one adapter and real collateral through the other, borrow against the real balance, initiate liquidation through the dust adapter because its health check is independently evaluated, and have settlement seize only dust while burning the entire aliased debt. The untouched balance remains withdrawable after settlement.
Impact: Under this configuration prerequisite, any caller can repeatedly cause StabilityPool repayment of the attacker's full debt while only dust collateral is seized, retain the borrowed crvUSD and real collateral, and drain depositor backing/DEToken solvency. The shared liquidation latch also freezes rescue and borrowing operations through the other adapter. The shipped deployment registers only one ERC20 adapter, and registration is PROTOCOL_ROLE-only, so this is a configuration/migration hardening issue and is appropriately Medium rather than a default-deployment permissionless High finding.
Recommendation
Track the underlying token for each registered adapter and reject a second adapter for the same token, or require atomic unregistering of the superseded adapter during migration. Also include the adapter identity in each adapter's position key so accidental duplicate registration cannot alias debt/liquidation state.
Resolution
raac.io Team: The docs have been updated. We mention what type of adapters should be added.
-
L-06 Low Supply cap bypassed and avoids deposit fee Configuration Resolved
Description
The lending pool accepts deposits as soon as it is deployed. It is not paused. The constructor writes a supply cap of 10,000,000 pmUSD and never writes a deposit fee, so the fee stays 0 (
LendingPool.sol:233-241).The launch values are a 100,000 pmUSD cap and a fee of 5, which is 0.05% (
config-global.js:37and:40). They are written later, each in its own transaction, afterRToken.setLendingPoolopens minting (deployer.js:433, then:444and:447). Each call waits for a receipt. On Ethereum that is several blocks before the supply cap changes.LendingPool.depositchargesparameters.depositFee, then checks the cap on the amount after the fee (LendingPool.sol:307-309and:332-333). In that window both use the constructor values. The fee collector is already set. The parameter is 0.A supplier can deposit 200,000 pmUSD then. They receive 200,000 rToken and pay exactly 200,000. The fee collector receives nothing. The same call can reach 10,000,000.
After the script sets the fee to 0.05% and the cap to 100,000, the 200,000 stays. A later deposit reverts. The protocol never collects the 0.05%. On 200,000 that is 100 pmUSD. On a full 10,000,000 fill it is 5,000 pmUSD, and later suppliers are blocked until the balance falls below 100,000.
Recommendation
Call
_pause()at the end of the LendingPool constructor, and callunpause()only afterlinkLendingPoolhas written the launch parameters.depositalready reverts while the pool is paused (LendingPool.sol:303).Alternatively, consider updating the default values in the constructor, and avoid having to send extra config transactions to save gas.
-
L-07 Low Unguarded tier-0 oracle calls bypass fallbacks and freeze valuation-dependent operations denial-of-service Acknowledged
Description
The production tier-0 Chainlink path invokes latestRoundData(), decimals(), and normalization without try/catch or saturating arithmetic. A feed revert or normalization overflow therefore bubbles out instead of reaching the configured fallback. The PoC demonstrates this on the production-shaped iRAAC collateral route and LiquidationSwap, with healthy controls; valuation-dependent operations and NFT pricing also revert.
Impact: A feed outage or malformed answer temporarily freezes borrow, collateral valuation and health reads, indebted-position withdrawals, liquidation initiation, swaps used by liquidation routing, and RAACNFT mint pricing. RToken supplier withdrawals, debt repayment, deposits, and debt-free collateral withdrawals remain available. This is a recoverable availability/frozen-liquidation issue; no direct loss is demonstrated.
Recommendation
Guard the tier-0 latestRoundData() and decimals() calls and treat failed or unusable responses as a failed tier so the configured fallback can run. Check decimal-normalization bounds before arithmetic.
-
L-08 Low LiquidationFinalized reports zero collateral for ERC20 liquidations logic/event-accounting Resolved
Description
finalizeLiquidation transfers the borrower’s entire ERC20 balance before calling getAssetValue for the LiquidationFinalized event. transferAsset zeroes the adapter balance, so every ERC20 liquidation emits collateralLiquidated as zero despite transferring the collateral to the Stability Pool.
Impact: The canonical LiquidationFinalized event misreports seized ERC20 collateral as zero. Indexers, monitoring, and reconciliation systems that derive liquidation amounts from this event cannot account for Stability Pool collateral intake. No direct on-chain fund loss is shown.
Recommendation
Read and cache the collateral value before transferAsset, then emit the cached value (or otherwise compute it from the pre-transfer balance).
-
L-09 Low Unbounded ERC721 position enumeration can exhaust gas for debt reads and borrowing denial-of-service Resolved
Description
ERC721AssetAdapter records every NFT deposited by a user in an uncapped array, and getPositionKeys materializes and hashes the entire array. LendingPool.getPositionsScaledDebt enumerates those keys; DebtToken.balanceOf delegates to that aggregate calculation, and DebtToken.mint calls balanceOf on a new borrow. The PoC shows approximately linear gas growth and an account with 1,000 deposited NFTs exceeding the 16,777,216 per-transaction gas cap for the aggregate debt read and a subsequent borrow.
Impact: An account with an exceptionally large NFT portfolio can make its own aggregate DebtToken.balanceOf and further borrowing run out of gas. The effect is self-scoped and recoverable: another user cannot enlarge the list, repayment and liquidation remain position-specific, and debt-free NFTs can be withdrawn individually. The PoC reaches this count using privileged zero-price minting; buying roughly 1,000 priced property NFTs would be expensive. NFT lending is not planned for the current launch. No lender loss, liquidation failure, trapped funds, or protocol-wide denial of service was demonstrated.
Recommendation
Document how aggregate debt reads and new borrowing scale with a user's deposited NFT count, including the supported portfolio size. If large NFT portfolios are expected, avoid the full-account enumeration in the borrow path and provide a paginated position-read API.
-
L-10 Low Blacklisted spender bypasses RToken compliance via transferFrom allowances compliance/access-control Acknowledged
Description
RToken._update() (RToken.sol:324-329) and DEToken._update() (DEToken.sol:281-286) validate only from and to addresses. ERC20.transferFrom spends the caller's allowance but invokes
_update(sender, recipient, amount), so a blacklisted caller is never checked. Neither token checks the spender in approve or in the inherited ERC20Permit.permit, so a blacklisted spender can also obtain a fresh allowance from a holder's off-chain signature after being blacklisted and spend it in the same transaction. The PoC blacklists the spender after a clean holder approves it, shows that a direct transfer reverts, and shows that transferFrom(victim, clean, fullBalance) succeeds.Impact: A sanctioned/blacklisted operator with a pre-existing allowance can continue moving the approving holder's RToken to arbitrary un-blacklisted destinations via transferFrom, bypassing ComplianceRegistry's sanctions enforcement on outflows. This is a material compliance-control bypass enabling sanctions evasion, not direct theft beyond the allowance the clean holder granted. isBlacklisted() is not only the protocol's own list: on mainnet, ComplianceRegistry is wired to the Chainalysis sanctions oracle, and OFAC-listed addresses such as
0x7F367cC41522cE07553e823bf3be79A889DEbe1Band0x098B716B8Aaf21512996dC57EB0615e2383E2f96return true today. Those addresses are blocked from holding and transferring RToken/DEToken, but not from spending them as an approved operator. Regulated tokens close this path explicitly: USDC's FiatToken applies notBlacklisted(msg.sender) on transferFrom (alongside from and to) and on approve.Recommendation
Check complianceRegistry.isBlacklisted(msg.sender) in transferFrom before spending the allowance in both RToken and DEToken (or pass the spender into _update and validate it); consider applying the same compliance policy to approve/permit, while still allowing approve(spender, 0) so holders can revoke allowances.
-
L-11 Low Residual RToken cash has no defined claim path funds-locking Resolved
Description
After borrowers repay, protocol fees are claimed, and suppliers withdraw, reserve asset can remain in RToken. calculateDustAmount() displays cash above RToken totalSupply(), but rescueToken() rejects the reserve asset and the normal fee claim transfers only the recorded pending fee. The remaining cash has no defined beneficiary or routine claim path.
The owner can temporarily set itself as RToken's lending pool and use the uncapped transferAsset() to move funds. That requires changing a live authorization pointer and manually deciding how much is safe to take. calculateDustAmount() is not a safe withdrawal limit while claims remain: with 100 cash, 80 RToken supply, and 10 pending fees, it reports 20 as dust although only 10 is unallocated after the fee claim.
Recommendation
Be aware of and document whether residual cash is meant to remain as extra backing or be allocated to a recipient. If recovery is intended, provide a bounded path based on all outstanding RToken and fee claims. Do not authorize a sweep using calculateDustAmount() alone.
-
L-12 Low Harvest can make vault rebalance revert DoS Resolved
Description
When a pmUSD savings vault is configured,
VaultProxy.rebalanceLiquidity()chooseswithdrawAmount = min(shortage, maxWithdraw)before callingwithdrawFromVault(). The latter first harvests yield by redeeming vault shares, then rechecks the now lowermaxWithdrawagainst the original amount. A routine deposit or protocol-fee claim can therefore revert during the optional buffer rebalance even when nearly all of the requested amount remains withdrawable.In a controlled replay with a 100% buffer, the requested withdrawal exceeded the post-harvest limit by two wei and the entire transaction reverted. This requires a configured vault whose withdrawal limit falls when yield shares are redeemed. The launch plan leaves the savings vault unset; the replay uses a simplified vault mock, so the exact rounding behavior has not been verified against a future pmUSD vault.
Recommendation
Be aware of and document this behavior before enabling a pmUSD savings vault. For optional buffer rebalancing, determine the withdrawable amount after harvesting and cap the rebalance to that updated amount. Retain strict liquidity checks for exact user payouts.
-
L-13 Low Liquidation rebalances the vault before repayment accounting Partially resolved
Description
LiquidationProxy credits reserve.totalLiquidity with the liquidation debt before the StabilityPool transfers reserve assets. LendingPool.finalizeLiquidation immediately rebalances against that higher total, so a funded vault with a buffer below 100% is asked to release part of its principal before the repayment arrives. In the real-stack PoC, a reserve whose buffer was on target withdraws 30,000 from the vault before a 150,000 debt repayment at a 20% buffer. A separate yield-bearing state shows that the pre-harvest versus post-harvest maxWithdraw mismatch can make this withdrawal revert.
Impact: In the demonstrated RToken-funded liquidation, the extra vault withdrawal is an intermediate movement of cash within the same transaction. The StabilityPool then redeems RTokens and transfers repayment to RToken, restoring the target buffer. For the stated strategy-free vault with no exit penalty, this movement does not realize a withdrawal fee, slippage, or loss of depositor principal. Its relevant consequence is an additional vault call that may hit a withdrawal limit; the post-harvest limit failure is covered by the separate 'Harvest can make vault rebalance revert' draft. No independent lasting fund loss is shown.
Recommendation
Be aware of and document that finalization can call the yield vault before the Stability Pool returns reserve cash, even when the subsequent RToken-funded settlement restores the buffer in the same transaction. Keep the liquidation settlement independent of optional vault rebalancing if the selected vault's withdrawal limit can change during harvest.
Resolution
Guardian: The rebalance was removed from finalizeLiquidation, but the StabilityPool's withdraw still rebalances against the repayment before it arrives, so the vault is still drawn on early.
-
L-14 Low Insecure production deployment configuration Configuration Resolved
Description
Independent defects in the mainnet deploy scripts:
-
Sepolia oracle config.
deploy_aws.js:16builds the oracle deployer fromconfigs.network.sepolia. On mainnet it sets a feed with no code, hands ownership to the Safe, then reverts in its owngetLatestPricecheck. -
Oracle deployed instead of failing. With
TOKEN_TO_USD_ORACLEempty,deployer.js:118-122treats networkhardhatas a local run. The documented command has no--network, yet transactions go to mainnet through the script's ownRPC_URLsigner. It deploys a feedlessPmUSDToUSDOraclereturning $1, whichtransferOwnershipskips as "already owned by multisig";LiquidationSwapandRWAIndexTokenOracleuse it. -
Fees to a destroyed key. The process destroys the deploy key, yet
config-global.js:62-66makes it the repair, burn, veRAAC, RAAC Corp and other recipient: 75% of lending yield, all NFT sale proceeds and LP rewards. Fees distributed before the Safe repoints them are lost. -
Not the KMS key.
deploy_beta_aws.js:14-26signs with a rawPRIVATE_KEYfrom Secrets Manager, so disabling the KMS key does not retire it. -
Exchange-rate feed. Feed
0x922a…("PMUSD / USD Exchange Rate") was exactly 1.0 in all 210 daily rounds, so the 0.99-1.01 breaker is unlikely to see a depeg. -
Minor.
precheckfunds 25M gas; the deploy uses at least 31M. The multisig note namessetPmUSDToUSDOracle, which does not exist (RAACHousePrices.sol:191).
Recommendation
-
Load
configs.network.mainnetin the oracle script. -
Stop when
TOKEN_TO_USD_ORACLEis empty instead of deploying one. -
Refuse to run while the owner is not the Safe or a fee recipient is the deployer.
-
Sign with the KMS signer, as the oracle script does.
-
Use a market-price pmUSD feed, or document that depegs are not detected.
-
Raise the gas budget and fix the setter name in the multisig note.
-
-
I-01 Informational Insurance fee omits accrued interest Logical Error Resolved
Description
borrowWithInsurancequotes the premium fromgetUninsuredBalanceplus the new principal before_borrow. That helper israwDebtBalance - rawInsuredBalance. It does not include pending interest._borrowthen mints debt.DebtToken.mintcapitalizesscaledDebt - rawDebtasuserIncreaseand the pool adds that amount torawDebtBalance. The wrapper still charges the old quote, adds onlyuninsuredBalance + amounttorawInsuredBalance, and setsisInsuredto true. Liquidation grace, close, and repay while flagged all check that flag, not the insured ledger, so the uncharged interest receives the same 3 day repayment window as the paid notional.The path needs existing debt and time since the last mint or burn. A first insured borrow has no
userIncrease. The caller must still borrow at leastMIN_BORROW_AMOUNTand stay insideborrowThreshold.Docs say this call insures the entire outstanding debt. After accrual it does not. The fee collector misses
userIncrease * insuranceFee.Raw uninsured debt 1000 with a 10% index rise is scaled 1100. Borrowing 10 more charges
(1000 + 10) * 3% = 30.3instead of(1100 + 10) * 3% = 33.3. After the call, raw debt is 1110, raw insured is 1010, andisInsuredis true.uint256 uninsuredBalance = getUninsuredBalance(adapter, msg.sender, data); uint256 insuranceFeeAmount = calculateInsuranceFee(adapter, msg.sender, data, amount); ... _borrow(adapter, data, amount); ... position.isInsured = true; position.rawInsuredBalance += uninsuredBalance + amount;Recommendation
When
isInsuredis set, the collected premium andrawInsuredBalancemust cover current outstanding debt, including pending interest. Include scaled uninsured debt, or the mintuserIncrease, in the fee and inrawInsuredBalanceso the premium matches the debt that receives grace. -
I-02 Informational Documented incident burn of collateralized NFT leaves unsecured, forcibly-unrecoverable debt collateral-accounting Resolved
Description
RAACNFT.burn is the documented security-incident burn operation, but it destroys an adapter-custodied token without notifying ERC721AssetAdapter or clearing its valuation/custody state. Existing debt remains validated, while liquidation and withdrawal reach safeTransferFrom on the nonexistent token and revert. The PoC also confirms voluntary repayment remains possible in the burn-only path, but forcible seizure cannot complete; zeroing the quote does not repair the stuck debt. If reminting is allowed, redeposit can additionally overwrite the reverse owner, freeze the prior position, and double-count the ID.
Impact: An incident burn of an indebted collateral NFT leaves an unsecured loan that cannot be forcibly recovered through normal on-chain liquidation or withdrawal; only optional borrower repayment can clear it. On default, reserve/RToken suppliers bear the debt, and a stale nonzero quote can permit further borrowing. The remint/redeposit path can freeze voluntary repayment and create two debts against one NFT. Recovery requires privileged state/custody repair.
Recommendation
Consider documenting this scenario in the burn function natspec, so the admin role addresses do not burn NFTs held by the ERC721 adapter.
-
I-03 Informational Protocol fees are paid from depositor principal before borrower interest is realised economic-accounting Resolved
Description
Interest accounting creates a pending protocol fee from the outstanding debt index even though no borrower cash has entered the pool. A fee claim then ensures liquidity by withdrawing from the RToken buffer or vault and transfers that amount to the collector. With a zero buffer, this directly burns vault shares and removes depositor principal while the debt remains unpaid.
Impact: The production-entry-point PoC shows a 1108.59 crvUSD claim funded entirely by vaulted depositor cash while DebtToken supply is unchanged. After the same external collateral-price collapse and borrower non-payment, full supplier withdrawal reverts in both controls, but the world that claimed first recovers exactly 1108.59 crvUSD less than the unclaimed world. Thus routine fee collection is senior to suppliers and crystallizes protocol cash before an accrued receivable is realized; suppliers absorb the corresponding loss when the receivable defaults.
Recommendation
First impression will be to make protocol fees withdrawable from interest actually collected, but this requires quite a few changes in the logic. Assuming a liquidity buffer below 100%, a simpler solution will be to only allow protocol fee claiming when there is enough available liquidity, instead of using the depositor's funds from the vault.
-
I-04 Informational Large liquidations miss the slippage floor DoS Resolved
Description
Liquidation sells the seized collateral in one trade. There is no partial close. The stability pool burns 2.5% of the index token, then sells 75%. The slippage protection makes the floor 1% under the vault price per share, and that price is read after the burn.
The burn raises the share price, so the floor moves up. A large sale also moves the pool due to price impact. Those two effects stack. At the deploy setting of 100 bps, a small sale in the liquidation flow still clears. A sale that is large relative to the pool liquidity does not. The exchange reverts, and the liquidation reverts with it. Most of the gap is price impact. The burn is a smaller push in the same direction.
One single position can borrow a significant piece borrow cap. Nothing else limits that debt, apart from the position's own collateral. The result is a position big enough that can't be liquidated
Recommendation
Add a maximum debt per position, separate from the global borrow cap. Set it so a full liquidation of that position still clears the 1% floor after the 2.5% burn and the 75% sale. One position can borrow the whole global cap, so that cap alone does not bound the trade.
-
I-05 Informational Public liquidation debt helper reverts for all direct callers availability Resolved
Description
getPositionScaledDebtProxy is exposed as a public view function without onlyProxy, yet forwards via ILendingPool(address(this)).getPositionScaledDebt(...). A standalone LiquidationProxy has no such selector, so every direct ABI invocation reverts instead of returning a value or the descriptive guard error used by other proxy entrypoints.
Impact: The helper function is unusable for direct integrations, monitoring and simulation callers, and its failure mode is an opaque revert. The PoC deploys the real stack and confirms the pool implementation works while direct proxy calls revert. No current on-chain flow loses funds, so impact is limited to low-severity API availability/correctness.
Recommendation
Make the helper function internal. If a direct integration API is intended, forward to a stored LendingPool address rather than address(this) or create a separate helper public function away from the proxy contract.
Resolution
raac.io Team: Hello, thanks for this finding. A fix has been applied.
-
I-06 Informational Non-receiver contract accounts cannot withdraw ERC721 collateral availability Acknowledged
Description
ERC721AssetAdapter.deposit accepts a contract account as the depositor and pulls its NFT with transferFrom, without checking IERC721Receiver support. On withdrawal, the adapter always calls safeTransferFrom back to that same account, and LendingPool.withdrawAsset offers no alternate recipient. A non-receiver account therefore cannot use the ordinary debt-free withdrawal path; the transaction reverts and the NFT remains in the adapter. An upgradeable account could later add the receiver callback. NFT lending is not planned for the current launch.
Impact: This is a self-scoped compatibility risk for a contract account that chooses to deposit an NFT it cannot safely receive back. No third-party theft or protocol-wide loss is demonstrated.
Recommendation
Document that contract accounts depositing ERC721 collateral must implement IERC721Receiver and that ordinary withdrawals return the NFT only to the depositor. If non-receiver accounts are intended to be supported, add a withdrawal recipient chosen by the depositor.
Resolution
raac.io Team: Hello, thanks for this finding. It is the responsibility of the depositor to ensure that the account is a valid NFT receiver. We don't believe that the adapter should not be responsible for self sabotaging actions of external actors. The current implementation will remain as is.
-
I-07 Informational InsuredAsset emits only the pre-existing uninsured balance data-integrity Resolved
Description
borrowWithInsurance increases rawInsuredBalance by uninsuredBalance + amount but emits InsuredAsset with uninsuredBalance only. The first insured borrow therefore emits amount 0, and later insured borrows omit the newly borrowed insured principal.
Impact: Consumers that build insurance/risk/accounting state from InsuredAsset logs under-report newly insured debt. The on-chain position is correct and no direct fund loss is shown, so the impact is limited to off-chain data integrity.
Recommendation
Document that InsuredAsset.amount reports only the previously uninsured balance converted by borrowWithInsurance; the separate Borrow event reports new principal. If the event is intended to report all debt newly insured by the call, emit uninsuredBalance + amount instead.
Resolution
raac.io Team: Hello, thanks for this finding. A fix will be applied to ensure the InsuredAsset event emits correct data.
-
I-08 Informational FeeCollector pause blocks liquidations DoS Resolved
Description
Every liquidation sends 20% of the minted iREET, the liquidity reward leg, through
IFeeCollector(feeCollector).collectFee(...)with notry/catch(LiquidationStrategyProxy.sol:153-160, split inconfig-global.js:10-15).collectFeeiswhenNotPaused(FeeCollector.sol:318). It also reverts if the iREET token or theRWA_INDEX_TOKEN_LIQUIDITY_REWARDSfee type is not registered (:320-321). Any of these reverts the wholeliquidateBorrower.EMERGENCY_ROLEcan pause the FeeCollector at any time, andemergencyWithdrawonly works while paused (:430). The collector's own incident flow keeps liquidations down for as long as the incident lasts.The docs say "liquidateBorrower deliberately stays available while paused". The LendingPool lets the StabilityPool bypass its pause "for liquidations" (
LendingPool.sol:301,:342). The FeeCollector pause has no such exception, and the docs never mention a FeeCollector call on the liquidation path.The fix for "LendingPool.pause() blocks StabilityPool.liquidateBorrower via updateState" does not cover this. To liquidate, the team must unpause, liquidate and re-pause in one transaction, or change the split before each liquidation. Neither is documented.
Recommendation
Wrap the
collectFeecall intry/catch. On failure, send the leg to the Treasury or keep it in the StabilityPool for a later sweep, so a collector-side pause or configuration cannot block a liquidation. Document the behavior next to the existing pause carve-outs. -
I-09 Informational NFT flagged while price update is pending Oracle Resolved
Description
A house is marked pending only when its on-chain price differs from the valuation the CRE will write. A pending price is known to be outdated.
initiateLiquidationvalues NFT collateral throughRAACHousePrices.getLatestPrice(ERC721AssetAdapter.sol:117-121), which ignoresisPendingRequest(RAACHousePrices.sol:201-209). Anyone can flag a position on the outdated price while the pending valuation is healthy.An uninsured borrower then cannot repay or close (
LendingPool.sol:592), and the flag is never re-evaluated. The house can be seized after the update lands. The docs recommend a settlement guard in consuming protocols for pending prices.Recommendation
Revert
initiateLiquidationfor ERC721 collateral whileisPendingRequest(tokenId)is true. If this is the intended behavior, consider documenting the liquidation scenario. -
I-10 Informational Incorrect NatSpec and documentation Documentation Partially resolved
Description
Each item below is text that contradicts what the code does. None changes on-chain behaviour, but integrators and reviewers who rely on it get the wrong units, callers or behaviour.
Wrong on the docs site (and in the code's NatSpec):
-
calculateHealthFactorreturns "RAY, 1e27" (docslending-pool-functions; contract NatSpecLendingPool.sol:740). Indebted positions return 1e18-scaled values. Only the zero-debt value is RAY. -
The liquidity leg is deposited "into the Curve Gauge" (docs
stability-pool-liquidations,stability-pool-functions; contract NatSpecLiquidationStrategyProxy.sol:151). The code callsFeeCollector.collectFee. -
tokenOfOwnerByIndexdocuments both params as "The NFT tokenId" and the return as "Address of the user" (docslending-pool-asset-adapter-interface; interface NatSpecIERC721Adapter.sol:20-25). It takes a user and an index and returns a tokenId.
Wrong in code comments only:
-
[contract NatSpec]
setRWAVaultandsetLiquidationVaultTokenSplitsay "Sets the IndexToken contract address" (StabilityPool.sol:272,297). The split struct says "Index token minting fee" (StabilityPoolStorage.sol:28). -
[contract NatSpec]
getPendingProtocolFeeis documented as the normalized income (LendingPool.sol:852-853). -
[contract NatSpec]
DebtToken.decimalsis documented as the reserve asset address (DebtToken.sol:224-225). -
[interface NatSpec]
rawBalanceOfandrawTotalSupplysay "scaled" (IRToken.sol:62-73). They return non-scaled values, as the contract NatSpec says (RToken.sol:227-241). -
[interface NatSpec]
mintandburnsay the first return value is scaled (IRToken.sol:19,37). It is the underlying amount (RToken.sol:122,165). -
[contract NatSpec]
RToken.transferandtransferFrom"use scaled amounts" (RToken.sol:200,210). Both callsuperunchanged. -
[contract NatSpec]
RToken.rescueTokenis "Only callable by the Reserve Pool" (RToken.sol:366). It isonlyOwner. -
[interface NatSpec]
setParameteris "Only callable by contract owner" (ILendingPool.sol:177). The pool has no owner and gates by role. -
[contract NatSpec]
claimCollectorRewards"Claims CRV rewards from the vault" (LendingPool.sol:1112). It transfers booked reserve-asset yield. -
[contract NatSpec]
LiquidationStrategyProxy.liquidateBorroweris callable by "a manager or the owner when the contract is not paused" (:36). It isonlyProxy, and the StabilityPool entry has no pause check. -
[contract NatSpec]
finalizeLiquidationsays "If there is no debt, it will revert" (LiquidationProxy.sol:112). A zero-debt burn returns early (DebtToken.sol:155-157). -
[inline comment]
_repaysays amounts above the debt are capped (LendingPool.sol:600). Onlytype(uint256).maxis capped. -
[contract NatSpec]
setLiquidityPoolsets an "address and fee", with "10000 for 100%" protection (LiquidationSwap.sol:93,95). No fee is set, and 10000 givesminDy = 0. -
[contract and interface NatSpec]
withdrawmentions RAAC rewards (StabilityPool.sol:124,200,IStabilityPool.sol:8,20). None exist. -
[contract NatSpec]
ReserveDatastores "all values in RAY" (ReserveLibrary.sol:33). Liquidity and usage are in asset units. -
[contract NatSpec]
calculateLinearInterestreturns "the new liquidity index" (ReserveLibrary.sol:177-181). It returns an interest factor and ignoreslastIndex. -
[contract NatSpec]
calculateBorrowRateworks "within the maxRate and baseRate window" (ReserveLibrary.sol:298). Below the kink the rate is flat atoptimalRate. -
[inline comment]
_mintUserInterestsays_mintupdates_rawTotalDeposits(DEToken.sol:266). It does not. -
[contract NatSpec]
CrvUSDToUSDOracle.decimalsmentions "USD 8 decimals" (:96). It always returns 18. -
[inline comment] The
withdrawAllheader sends yield "to the reserve" and leftover shares "to the manager" (VaultProxy.sol:73,75). The code books yield as collector rewards and deposits the shares to the Treasury. -
[interface NatSpec]
transferAssetis callable by "privileged modules" (IAssetAdapter.sol:17). Both adapters allow only the lending pool. -
[contract NatSpec]
withdrawFromVaultandensureLiquidityare an "Internal function" (VaultProxy.sol:229,310). Both are public or external.
Recommendation
Update each listed NatSpec, comment and docs-site entry to describe what the code does.
Resolution
raac.io Team: Hello, thanks for this finding. A fix has been applied
Guardian: 16 of 26 items fixed. Still wrong in code: tokenOfOwnerByIndex return (IERC721Adapter.sol:17), getPendingProtocolFee "normalized income" (LendingPool.sol:856), IRToken "scaled total supply" (:70), RToken transfer "scaled amounts" (:200), SP withdraw "RAAC rewards" (StabilityPool.sol:124, IStabilityPool.sol:8), IAssetAdapter "privileged modules" (:18). Still wrong on the live docs: "RAY, 1e27" (lending-pool-functions), "Curve Gauge" (stability-pool-liquidations, stability-pool-functions), "Address of the user" (lending-pool-asset-adapter-interface).
-
-
I-11 Informational Unused code Superfluous Code Resolved
Description
The items below have no production reader or caller. Each was searched across
contracts/,deployment/,library/andtest/. They cost gas or mislead readers.Storage written but never read:
-
WithdrawTimelock.expireAt(StabilityPool.sol:150). Expiry usesreadyAt + withdrawTimelockDelay(:169). The docs still describeexpireAtas the end of the window. -
WithdrawTimelock.userIndexAtRequest(StabilityPool.sol:146), which also costs an external call per request. -
vaultRewards.lastSharePrice(VaultProxy.sol:86,133), only ever set to 0. -
vaultRewards.lastDepositTime,lastWithdrawalTimeandlastClaimTime(VaultProxy.sol:87,225,268,LendingPoolFeeLibrary.sol:59), visible only through the public getter.
Code that never runs:
-
LendingPool.sol:350comments out a call to_executeWithdrawTimelock, which is not defined anywhere. The comment on:349still describes a timelock. -
LendingPool.sol:413sets the health factor to RAY whenpositionDebt < 1, insideif (positionDebt > 0)(:408). It is unreachable. -
DEToken.setUserIndex(DEToken.sol:95-97) andDEToken.transferAsset(:163-165) have no caller outside tests. -
onlyManager(StabilityPool.sol:78-81) is never applied.
Declarations never used:
-
Errors never thrown:
CircuitBreakerTriggered(CrvUSDToUSDOracle.sol:278, the breaker clamps instead),TimeDeltaIsZero(ReserveLibrary.sol:110),NoDust(IRToken), and 27 inILendingPool,ILendingPoolStorageandIStabilityPool, such asNFTAlreadyDeposited,MarketNotFound,NoRewardsToClaimandRepayAmountTooHigh. -
Events never emitted:
RewardDistributed(VaultProxy.sol:354), the market and allocation events (IStabilityPool.sol:150-209),LiquidityIndexUpdated,BalanceTransfer,BurnerSet,DustTransferred(IRToken),UsageIndexUpdated(IDebtToken), andProtocolFeesCollected,ProtocolFeesWithdrawn,RewardTokensDeposited,VaultWithdrawalFailed(ILendingPool). -
Unused imports:
Address,IBaseCollector,IComplianceRegistry,DebtTokenandRTokeninLendingPool.sol;ILendingPoolandIAssetAdapterinVaultProxy.sol;SafeERC20inLiquidationProxy.sol;using SafeERC20 for IDETokeninStabilityPool.sol. -
Library helpers with no caller:
percentDiv,wadDiv,rayPowandWadRayMath.SECONDS_PER_YEAR.
Recommendation
Remove the unused imports, errors, events and helpers, the unreachable branch at LendingPool.sol:413 and the commented-out timelock call. Keep the unused storage fields, because StabilityPool is upgradeable and the proxy modules share storage through delegatecall. Mark them as unused in the NatSpec, and correct the docs entry for expireAt.
Resolution
raac.io Team: Hello, thanks for this finding. The unused elements raised will be assessed and removed where necessary.
-
-
I-12 Informational YieldHarvested reports zero priceIncrease on every harvest data-integrity Resolved
Description
VaultProxy._harvestYield emits YieldHarvested(yieldHarvested, 0, _getPricePerShare()) after a successful harvest. The event documents priceIncrease as the share-price change, but this field is always zero, and lastSharePrice is never updated after its initialization. A consumer that relies on priceIncrease alone will miss share-price appreciation. The event still provides yieldAmount and newSharePrice, and this mismatch does not alter on-chain balances. The PoC triggers two harvests with positive yield and rising newSharePrice; both report priceIncrease == 0.
Recommendation
Document that YieldHarvested.priceIncrease is currently always zero, so consumers should derive a share-price change from successive newSharePrice values if needed. Alternatively, populate priceIncrease from a tracked prior share price.
Resolution
raac.io Team: Hello, thanks for this finding. A fix has been applied
-
I-13 Informational Admin setters accept out-of-range values Validation Resolved
Description
LendingPool.setParameterstores the buffer ratio, insurance fee, deposit fee, protocol fee rate, optimal utilization, prime-rate step limit and vault opening fee unchecked, and boundsLiquidationThresholdonly byborrowThreshold.StabilityPool.setWithdrawTimelockDelayand the stale-time andKsetters ofCrvUSDToUSDOracleand its childPmUSDToUSDOracleare unchecked too.A bad value breaks or distorts a core flow. A deposit fee above 100% underflows
amount - depositFeeAmount(LendingPool.sol:308). An insurance fee above 100% charges more than the borrow (LendingPool.sol:536). A buffer ratio above 100% asks for more than the vault holds after harvest, sowithdrawFromVaultreverts (VaultProxy.sol:245) and every pool operation that rebalances reverts with it. A withdraw delay of 0 expires every StabilityPool withdraw request (StabilityPool.sol:169). A stale time of 0 pins both oracles to the fixed fallback.No setter relates
liquidationThresholdto the liquidation split. Debt is repaid only by swapping a share of the seized index tokens (LiquidationStrategyProxy.sol:65-83), so a threshold above swap share × (1 − slippage) makes every liquidation a StabilityPool shortfall, not only bad debt. Deployed 60% is safe (limit 74.6%); the 80% default is not.Recommendation
Reject the values that break operations: a deposit fee or buffer ratio above
100_00, a protocol fee rate aboveRAY, and a zero withdraw delay. Document the operating ranges for the other fees, the oracle stale times andK, and howliquidationThresholdrelates to the liquidation split's swap share.Resolution
raac.io Team: Hello, thanks for this finding. A fix has been applied
Guardian: Not bounded: StabilityPool.setWithdrawTimelockDelay accepts 0
-
I-14 Informational LiquidityRebalanced reports pre-rebalance buffer and tracked principal Events Resolved
Description
VaultProxy.rebalanceLiquidity reads currentBuffer before it deposits into or withdraws from the vault, then emits that old value in LiquidityRebalanced after the operation. Its vaultAmount field is vaultRewards.totalDeposits, a tracked principal value rather than the vault's current redeemable asset value. For example, moving 800,000 of a 1,000,000 deposit to the vault still emits a 1,000,000 buffer. A consumer treating the event as a post-rebalance allocation can therefore be misled; the event does not affect on-chain balances, and no dependent consumer is identified.
Recommendation
Be aware of and document that LiquidityRebalanced currently reports a pre-operation buffer and tracked vault principal. Consumers needing the final allocation should read actual balances and vault conversion state. If the event is intended to report final allocation, emit post-operation cash and current vault asset value.
Resolution
raac.io Team: Hello, thanks for this finding. A fix has been applied
Guardian: The buffer is now post-rebalance. vaultAmount is still vaultRewards.totalDeposits
Remediation Review
1 finding · October 24 to 28, 2026-
I-01 Informational Liquidation still draws on the vault before repayment arrives Gas Optimization Resolved
Description
The fix removed the rebalance from
finalizeLiquidationand added a finalrebalanceLiquidity()(LiquidationStrategyProxy.sol:92). The early vault draw it targeted still happens, one step later.finalizeLiquidationstill creditsreserve.totalLiquiditywith the burned debt before any cash arrives (LiquidationProxy.sol:152). In an RToken-funded liquidation, the StabilityPool then callslendingPool.withdraw(LiquidationStrategyProxy.sol:78).withdrawends with_rebalanceLiquidity()(LendingPool.sol:364), which sizes the buffer against the inflated total and pulls the difference from the vault. The repayment then lands in the RToken (:81), and the final rebalance deposits the same amount back.As a result, every RToken-funded liquidation withdraws from the vault and then deposits the same amount back within one transaction, each leg with its own harvest. No funds are lost with a vault that has no exit fee, and the withdrawal no longer reverts. With a vault that charges on exit or limits withdrawals, the round trip pays that fee or consumes that limit for nothing.
Recommendation
Skip the rebalance in
withdrawwhen the caller is the StabilityPool, and let the finalrebalanceLiquidity()inLiquidationStrategyProxysettle the buffer once. Liquidation is the StabilityPool's only call towithdraw, and every liquidation path already ends with a rebalance.
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.
