RAAC
Brings real estate on-chain — properties become NFTs that holders borrow against, with a stablecoin lending market and RAAC token emissions.
- H-01RToken mint overestimates interest accrual, letting users receive excess tokens
- H-02Token decimal mismatch in ZENO redemption leads to inaccurate USDC payouts
- H-03RAACNFT collects ERC20 payments with no way to withdraw them
- H-04Double debt-index multiplication overestimates debt when minting debt tokens
- M-01RAAC emission rate can be manipulated by cycling StabilityPool deposits
- M-02Treasury.allocateFunds has no token parameter, causing allocation tracking errors
- M-03calculateDustAmount divides the contract balance by the liquidity index, underestimating funds
- M-04DebtToken.totalSupply divides by normalized debt instead of multiplying, underestimating debt
- M-05Treasury._totalValue sums raw token amounts across tokens, misreporting holdings
- L-01Governance doesn't forward ETH and the TimelockController can't be funded, so ETH proposals fail
- L-02RToken.mint returns values in the wrong order versus its NatSpec, breaking integrations
- L-03FeeCollector swap-tax and NFT-royalty basis points are 10x too high
RToken mint overestimates interest accrual, letting users receive excess tokens
Summary
The RToken.mint() function calculates accrued interest incorrectly, overestimating the additional balance a user has earned. This lets users receive more tokens than they should, inflating the token supply and distorting the protocol's accounting.
Vulnerability detail
The issue is in RToken.sol#L130-L132:
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
The contract tries to compute the extra balance a user has gained from interest, but the subtraction overestimates it. rayMul(index) applies a scaling factor meant for underlying-asset calculations, yet scaledBalance here is already the scaled balance returned by balanceOf, which multiplies the raw balance by the normalized income:
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedIncome());
}
So the index is applied twice.
Impact
Users receive more tokens than they are owed, leading to token-supply inflation and a broken protocol balance.
Recommendation
Use the already-scaled balance for the balance-increase calculation and do not multiply it by the index a second time.
Token decimal mismatch in ZENO redemption leads to inaccurate USDC payouts
Summary
The ZENO token contract lets users redeem tokens for USDC 1:1, but ZENO inherits OpenZeppelin's default 18-decimal ERC20 while USDC uses 6 decimals. The redemption path transfers a raw ZENO amount as if it were a USDC amount, so it moves vastly too much or too little USDC.
Vulnerability detail
Both redeem and redeemAll call:
USDC.safeTransfer(msg.sender, amount);
at ZENO.sol#L62 and ZENO.sol#L73.
amount is the ZENO token amount in 18-decimal units. USDC is 6-decimal. Redeeming 1 ZENO (1e18) attempts to transfer 1e18 USDC units — 1e12 USDC. There is no conversion factor and no decimals() override to align the two scales.
Impact
Redeemers receive an incorrect amount of USDC — either a massive overpayment, or a revert on insufficient contract balance.
Recommendation
Either override decimals() in ZENO to return 6, or introduce a conversion factor in the redemption functions that scales amount from 18 to 6 decimals.
RAACNFT collects ERC20 payments with no way to withdraw them
Summary
RAACNFT collects ERC20 tokens during NFT minting but has no function to withdraw or manage those funds. The tokens are permanently stuck in the contract.
Vulnerability detail
During minting, users pay with an ERC20 transfer into the contract:
token.safeTransferFrom(msg.sender, address(this), _amount);
The contract refunds any overpayment, but there is no mechanism — for the owner or anyone else — to withdraw the accumulated payment tokens. They remain locked indefinitely.
Impact
All ERC20 payments collected by RAACNFT are irrecoverable, causing a direct loss and preventing the protocol from reinvesting or distributing that revenue.
Recommendation
Add an access-controlled withdrawal function that lets the owner transfer the collected ERC20 tokens out of the contract.
Double debt-index multiplication overestimates debt when minting debt tokens
Summary
DebtToken.mint() applies the debt index twice when calculating accrued interest, overestimating the debt increase so users accrue more debt than they actually owe.
Vulnerability detail
When minting new debt tokens the function calls the overridden balanceOf, which already returns a balance scaled by the normalized debt. The code then multiplies that value again by both the new and old debt indexes to compute the accrued interest — inflating balanceIncrease and producing an erroneous debt balance.
Impact
Users are overcharged: they owe more than the correct amount because of an inflated interest component, and the protocol's debt accounting becomes unreliable for repayments and interest accrual.
Recommendation
Work from the raw scaled balance (the base ERC20 balance) rather than the already-indexed balance:
uint256 rawScaledBalance = super.balanceOf(onBehalfOf);
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = rawScaledBalance.rayMul(index) - rawScaledBalance.rayMul(_userState[onBehalfOf].index);
}
This ensures the debt index is applied only once.
RAAC emission rate can be manipulated by cycling StabilityPool deposits
Summary
An attacker can inflate the RAAC token emission rate by manipulating the utilization rate: withdraw a large portion of rToken deposits from the StabilityPool to spike utilization, trigger an emission-rate update, then redeposit — repeating to push emissions toward the cap.
Vulnerability detail
RAACMinter sets the emission rate from the utilization rate:
function getUtilizationRate() internal view returns (uint256) {
uint256 totalBorrowed = lendingPool.getNormalizedDebt();
uint256 totalDeposits = stabilityPool.getTotalDeposits();
if (totalDeposits == 0) return 0;
return (totalBorrowed * 100) / totalDeposits;
}
A large withdrawal drops totalDeposits, spiking the calculated utilization. The next call to tick() (callable by anyone) updates the emission rate upward:
function tick() external nonReentrant whenNotPaused {
if (emissionUpdateInterval == 0 || block.timestamp >= lastEmissionUpdateTimestamp + emissionUpdateInterval) {
updateEmissionRate();
}
// ... mints emissionRate * blocksSinceLastUpdate to the stability pool
}
calculateNewEmissionRate() raises the rate whenever utilization exceeds the target. Each update is capped at +5% and the rate is bounded by maxEmissionRate, but the attack loop — withdraw, tick(), redeposit — can be repeated to ratchet the rate to its ceiling while the attacker keeps their funds.
Impact
The attacker earns extra RAAC by repeatedly forcing emission increases. Cumulative manipulation across cycles produces significant over-minting and an unfair reward distribution, destabilizing the incentive structure.
Recommendation
Enforce a cooldown or deposit lock that prevents rapid withdraw/redeposit cycles from influencing the emission rate.
Treasury.allocateFunds has no token parameter, causing allocation tracking errors
Summary
Treasury.allocateFunds takes only a recipient and an amount, with no way to say which ERC20 is being allocated — even though the treasury holds multiple tokens. Allocations cannot be reliably correlated with actual token balances.
Vulnerability detail
function allocateFunds(
address recipient,
uint256 amount
) external override onlyRole(ALLOCATOR_ROLE) {
if (recipient == address(0)) revert InvalidRecipient();
if (amount == 0) revert InvalidAmount();
_allocations[msg.sender][recipient] = amount;
emit FundsAllocated(recipient, amount);
}
The _allocations mapping is keyed only by allocator and recipient. With a multi-token treasury there is no way to know which token an allocation refers to.
Impact
Funds cannot be matched to allocations, leading to misallocation, disputes over allocation records, and governance/accounting confusion. Funds are not directly at risk but treasury transparency is undermined.
Recommendation
Add a token address parameter and key allocations by token:
function allocateFunds(address token, address recipient, uint256 amount)
external override onlyRole(ALLOCATOR_ROLE)
{
if (token == address(0)) revert InvalidAddress();
if (recipient == address(0)) revert InvalidRecipient();
if (amount == 0) revert InvalidAmount();
_allocations[msg.sender][token][recipient] = amount;
emit FundsAllocated(token, recipient, amount);
}
mapping(address => mapping(address => mapping(address => uint256))) private _allocations;
calculateDustAmount divides the contract balance by the liquidity index, underestimating funds
Summary
RToken.calculateDustAmount divides the raw contract balance by the normalized income, understating how much the contract actually holds.
Vulnerability detail
uint256 contractBalance = IERC20(_assetAddress).balanceOf(address(this)).rayDiv(ILendingPool(_reservePool).getNormalizedIncome());
balanceOf(address(this)) is already a raw token amount. Dividing it by the normalized income shrinks the value for no reason.
Impact
Surplus / dust calculations are wrong, so proper fund management is blocked and tokens can sit unclaimed in the contract.
Recommendation
Use the raw balance directly and remove the rayDiv.
DebtToken.totalSupply divides by normalized debt instead of multiplying, underestimating debt
Summary
DebtToken.totalSupply scales the stored total supply by dividing it by the normalized debt rather than multiplying, so the protocol's total outstanding debt is reported lower than it is.
Vulnerability detail
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
return scaledSupply.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
}
The overridden balanceOf correctly multiplies the raw balance by the normalized debt. totalSupply inverts that operation, so the aggregate and the per-user views disagree.
Impact
Total outstanding debt is misreported (lower than reality), misleading users and stakeholders and creating inconsistencies in interest calculations and liquidity assessments.
Recommendation
Multiply instead of divide:
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledSupply = super.totalSupply();
return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
Treasury._totalValue sums raw token amounts across tokens, misreporting holdings
Summary
Treasury._totalValue is meant to track the total value of treasury assets, but it is updated by simply adding or subtracting raw token amounts on every deposit and withdrawal — with no regard for token decimals or price. Since the treasury holds multiple ERC20s, the figure is meaningless.
Vulnerability detail
Deposit (Treasury.sol#L52):
_totalValue += amount;
Withdraw (Treasury.sol#L74):
_totalValue -= amount;
Depositing 1 USDT (6 decimals) and 1 WETH (18 decimals) both bump _totalValue by 1, despite wildly different real values. Withdrawals decrement it the same way.
Impact
Governance, allocations and withdrawals that rely on _totalValue operate on a distorted number, risking financial mismanagement.
Recommendation
Do not track _totalValue as a sum of raw balances. Compute it dynamically from an oracle-priced valuation that accounts for each token's price and decimals.
Governance doesn't forward ETH and the TimelockController can't be funded, so ETH proposals fail
Summary
Proposals that need to send ETH can't execute: Governance.execute is non-payable so it can't forward ETH, and TimelockController has a payable executeBatch but no receive/fallback to be funded, so it can only ever hold ETH that arrives through its own payable calls.
Vulnerability detail
function execute(uint256 proposalId) external override nonReentrant {
// ... queues or executes the proposal, ultimately calling
// timelock.executeBatch(...)
}
It is not payable, so no ETH sent with the call can reach _executeProposal → executeBatch.
TimelockController.sol#L162-L185:
function executeBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
bytes32 predecessor,
bytes32 salt
) external override payable nonReentrant onlyRole(EXECUTOR_ROLE) {
for (uint256 i = 0; i < targets.length; i++) {
(bool success, bytes memory returndata) = targets[i].call{value: values[i]}(calldatas[i]);
if (!success) revert CallReverted(id, i);
}
}
executeBatch can forward ETH to targets, but the timelock has no way to receive plain ETH transfers, so it can't be pre-funded.
Impact
Any proposal that requires an ETH transfer will fail unless the timelock happens to already hold enough ETH via some external process — halting or delaying governance actions that move ETH.
Recommendation
Make Governance.execute payable so it can forward ETH, and add a receive/fallback to TimelockController so it can be funded directly.
RToken.mint returns values in the wrong order versus its NatSpec, breaking integrations
Summary
RToken.mint documents one return order but returns another — amountScaled and amountToMint are swapped. Callers that follow the NatSpec use the wrong values.
Vulnerability detail
The NatSpec declares:
* @return A tuple containing:
* - bool: True if this is the first mint for the recipient, false otherwise
* - uint256: The amount of scaled tokens minted
* - uint256: The new total supply after minting
* - uint256: The amount of underlying tokens minted
i.e. (isFirstMint, amountScaled, totalSupply(), amountToMint).
The actual return statement:
return (isFirstMint, amountToMint, totalSupply(), amountScaled);
ReserveLibrary.deposit destructures the result per the documented order:
(bool isFirstMint, uint256 amountScaled, uint256 newTotalSupply, uint256 amountUnderlying) =
IRToken(reserve.reserveRTokenAddress).mint(...);
so it silently binds the wrong values.
Impact
Any integrator relying on the documented return order — including ReserveLibrary — misinterprets mint's output and performs downstream calculations with swapped values.
Recommendation
Fix the return statement to match the documented order.
FeeCollector swap-tax and NFT-royalty basis points are 10x too high
Summary
FeeCollector sets feeTypes[6] (swap tax) and feeTypes[7] (NFT royalties) using basis-point values that are 10x the intended percentages — 500/1000 where the comments say 0.5% / 1.0%.
Vulnerability detail
// Buy/Sell Swap Tax (2% total)
feeTypes[6] = FeeType({
veRAACShare: 500, // 0.5%
burnShare: 500, // 0.5%
repairShare: 1000, // 1.0%
treasuryShare: 0
});
// NFT Royalty Fees (2% total)
feeTypes[7] = FeeType({
veRAACShare: 500, // 0.5%
burnShare: 0,
repairShare: 1000, // 1.0%
treasuryShare: 500 // 0.5%
});
On a 10,000 basis-point scale, 0.5% is 50 and 1.0% is 100. Using 500 and 1000 makes the shares 5% and 10%.
Impact
These fee components are charged at 10x the intended rate, distorting the protocol's fee distribution.
Recommendation
Use 50 and 100 where 0.5% and 1.0% are intended.