SECURITY AUDIT REGISTRY

RAAC

Brings real estate on-chain — properties become NFTs that holders borrow against, with a stablecoin lending market and RAAC token emissions.

CodeHawksCodeHawks
[H-01]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:

solidity
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:

solidity
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.

[H-02]high

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:

solidity
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.

[H-03]high

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:

solidity
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.

[H-04]high

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:

solidity
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.

[M-01]medium

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:

solidity
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:

solidity
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.

[M-02]medium

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

Treasury.sol#L87-L96:

solidity
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:

solidity
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;
[M-03]medium

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

RToken.sol#L319:

solidity
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.

[M-04]medium

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

DebtToken.sol#L232-L235:

solidity
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:

solidity
function totalSupply() public view override(ERC20, IERC20) returns (uint256) {
    uint256 scaledSupply = super.totalSupply();
    return scaledSupply.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
[M-05]medium

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):

solidity
_totalValue += amount;

Withdraw (Treasury.sol#L74):

solidity
_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.

[L-01]low

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

Governance.sol#L220-L242:

solidity
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 _executeProposalexecuteBatch.

TimelockController.sol#L162-L185:

solidity
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.

[L-02]low

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:

text
* @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:

solidity
return (isFirstMint, amountToMint, totalSupply(), amountScaled);

ReserveLibrary.deposit destructures the result per the documented order:

solidity
(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.

[L-03]low

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

FeeCollector.sol#L379-L393:

solidity
// 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.

STATUS: AVAILABLE FOR ENGAGEMENTS

Open to audits, contract work, and security writing.