Forest Road: Vault
Cantina Security Report
Organization
- @forestroad
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Informational
8 findings
1 fixed
7 acknowledged
Informational8 findings
Oversized redemption input panics before balance validation
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jiri123
Description
In
_quoteRedeem(), the redemption quote calculatesusdfrIn + drawnbefore checking the caller'sUSDfrbalance. A max-sized input with a nonzero draw therefore overflows and reverts, instead of returning the intended token or controller error. No funds are at risk because the caller cannot hold or burn such an amount.Recommendation
Check the value of
usdfrAmountagainst the caller's balance before calculating the quote.Forest Vault
Acknowledged the overflow panic at
usdfrIn + drawn, only reachable on the under-backed path, no funds at riskCantina Managed
Acknowledged
Invalid immutable module wiring requires an upgrade to recover
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jiri123
Description
In
initialize(), the controller only checks that theusdfr,compliance, andreservesaddresses are nonzero during initialization. A wrong or codeless address would leave the controller unusable, and the wiring cannot be corrected without an upgrade.This is limited to deployment misconfiguration and is mitigated by the hash-bound deployment manifest, post-deployment validation, and verification of the live addresses.
Recommendation
Retain immutable wiring, but ensure deployment validation checks that each address contains code and matches the expected module configuration before the deployment is accepted.
Forest Vault
Acknowledged as accurate with the wiring kept immutable, and the recommended validation is already in place, post-deploy checks assert controller.modules() against the manifest and confirm code through the ERC-1967 implementation slot, so the residual is limited to deployment misconfiguration and already mitigated.
Cantina Managed
Acknowledged
previewRedeem() can publish a redeem price that would not settle
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jiri123
Description
The
previewRedeem()view function is meant to publish the executable redemption price. However, it uses two checks that are not aligned with the logic of theredeem()function. It checks that none of theUSDfrand theMintRedeemControllercontracts are paused, but it does not check if theReserveManagercontract is paused, so it can return a full quote while thereleaseUSDC()function is blocked and every redemption reverts. It also passes drawn as zero into_quoteRedeem(), so in a below-par context, it publishes the gross marked price whileredeem()settles at the junior drawn price.Recommendation
Verify if the
ReserveManagercontract is paused alongside the other two contracts, and return(0, 0). Also, document the returned quote as a lower bound until the draw can be simulated.Forest Vault
Acknowledged the previewRedeem mismatch, which skips the ReserveManager pause check so it can quote a full price while redemptions revert, no funds at risk
Cantina Managed
Acknowledged
Code Overview
State
- Confirmed
Severity
- Severity: Informational
Submitted by
Jiri123
Scope
Cantina reviewed the forest-road-vault repository at branch
main, commit 71c285e. The following files were in scope:contracts/src/├── libraries/Roles.sol└── MintRedeemController.solCode Overview
MintRedeemController.solMintRedeemControlleris the protocol's USDfr supply gateway. It coordinatesUSDfr,ComplianceRegistry, andReserveManagerto provide:- KYC-gated USDC-to-USDfr minting.
- Direct USDfr redemption.
- Yield-backed protocol minting.
- Loss-absorption burns.
- Backing, deficit, redemption-price, and mint-capacity views.
The contract is a UUPS-upgradeable implementation using OpenZeppelin access control, pausing, and reentrancy protection. Its constructor disables initialization of the implementation contract.
Storage and initialization
ERC-7201 namespaced storage contains:
usdfr: the USDfr token.compliance: the user eligibility registry.reserves: the backing and USDC custody manager.yieldSink: governance-approved recipients of yield mints.lossSource: governance-approved contracts from which loss burns may occur.subParShortfall: cumulative value crystallized by below-par redemptions.
SCALE = 1e12converts between 6-decimal USDC and 18-decimal USDfr.initializeassignsDEFAULT_ADMIN_ROLE,GUARDIAN_ROLE, andUPGRADER_ROLEand records the three module addresses. The endpoint allowlists start empty, so credit-layer supply changes remain disabled until governance completes the wiring. The controller must separately receiveMINTER_ROLEonUSDfrandCONTROLLER_ROLEonReserveManager.Minting
mint(usdcAmount)is KYC-gated, pause-protected, and non-reentrant. It:- Rejects zero amounts, observable USDC custody shortfalls, and minting while supply already exceeds recorded backing.
- Pulls USDC from the user and temporarily approves
ReserveManager. - Calls
depositUSDC. - Independently verifies the reserve's exact USDC balance increase, the reported
usdcAmount * 1e12credit, the controller's unchanged USDC balance, and the corresponding backing increase. - Mints USDfr to the user.
- Verifies that the backing deficit did not increase.
Minting is always at par and remains closed while the protocol is under-backed.
Redemption
The contract exposes three overloads:
redeem(amount)applies a par minimum and therefore never silently haircuts the caller.redeem(amount, minUsdcOut)permits an explicitly accepted sub-par settlement.redeem(amount, minUsdcOut, deadline)additionally protects against delayed execution and is the canonical integration path.
The offered USDfr is rounded down to the whole-USDC grid:
usdfrIn = floor(usdfrAmount / 1e12) * 1e12Sub-unit dust remains in the user's wallet.
When backing covers supply, redemption settles at par. When the protocol is short, the controller first attempts to draw junior capital through
ReserveManager.lossAbsorber(). The draw target is:min(ceil(usdfrIn * (supply - backing) / backing), supply - backing)The draw source must be an authorized loss source. The controller measures the USDfr delivered by
drawForSeniorExit, rejects over-reporting or under-delivery, and burns the reported amount at the source. Production routes this draw throughDefaultManager, which uses curator first-loss capital before the sGROVE backstop.The short-state payout is derived from:
min(usdfrIn, (usdfrIn + juniorDrawn) * backing / supply)and rounded down to native USDC units. A partial junior draw improves the payout; a sufficient draw can restore it to par.
After checking
minUsdcOut, the controller burns the user's USDfr, releases USDC throughReserveManager, and verifies the user's exact USDC balance increase. Any unpaid value is added tosubParShortfall, preventing a later reversal of a conservative impairment from turning the exiter's crystallized loss into distributable yield.Redemption remains limited by idle USDC liquidity even when total backing includes deployed principal.
Protocol supply paths
mintYield(to, amount):- Requires
CREDIT_ROLE, an authorized yield sink, a nonzero amount, no controller pause, and the reentrancy lock. - Enforces non-worsening deficits on both recorded and recognition-aware backing.
- Requires the remaining recognized surplus to cover both
subParShortfallandReserveManager.exitPrepaidAbsorption().
In production,
WaterfallEngineholds this role and directs mints only to the sUSDfr vault or the configured fee recipient.burnLoss(from, amount):- Requires the separate
LOSS_BURNER_ROLE. - Restricts
fromto a governance-approved loss-source contract. - Is deliberately not pause-protected, allowing loss absorption to continue during an emergency.
- Needs no backing assertion because burning supply cannot increase a deficit.
Production grants this role to
DefaultManagerandReserveManager, not toWaterfallEngine.Backing model and views
The controller distinguishes two backing bases:
backingValue()usesReserveManager.totalBackingValue(), the recorded accounting ledger.recognizedBackingValue()additionally reflects any observable idle-USDC custody shortfall.
Its principal solvency rule is:
max(0, supplyAfter - backingAfter) <= max(0, supplyBefore - backingBefore)When the protocol begins whole, this reduces to
supply <= backing. When already short, it allows operations that preserve or repair the deficit while refusing further dilution.mintableHeadroom()returns zero while either the controller or USDfr is paused. Otherwise it returns:max(0, recognizedBacking - supply - subParShortfall - exitPrepaidAbsorption)previewRedeem()returns(0, 0)while redemption is paused, the reserve has a custody shortfall, the protocol is empty, or the amount is below the settleable floor. It does not simulate the junior draw, so an executable redemption may settle above the previewed floor, but the draw itself cannot make it settle below that quote.Composite views revert while the reentrancy guard indicates an unfinished supply transition. Raw single-module readings remain available because each remains individually accurate during the transition.
Administrative and security boundaries
DEFAULT_ADMIN_ROLEmanages roles, yield sinks, and loss sources.GUARDIAN_ROLEpauses minting, redemption, and yield minting.UPGRADER_ROLEauthorizes UUPS upgrades.- New loss sources must have deployed contract code and cannot be EIP-7702 delegated EOAs. Revocation remains unconditional.
- Governance can still authorize an unsuitable contract as a loss source; pro-rata burn safety therefore also depends on correct deployment wiring.
- Exact balance-delta checks make under-delivery, over-delivery, dishonest reserve accounting, and controller-side cash stranding fail closed.
- Instant redemption is first-come-first-served with respect to available idle liquidity.
Roles.solRoles.solis the generated canonical namespace for protocol role identifiers. It contains no storage, holder registry, or authorization logic. Each role is aninternal constantequal tokeccak256("<ROLE_NAME>").Its source of truth is
config/privilege-topology.json; the generated Solidity file should not be edited directly.Role identifiers are shared across the system, but grants are local to each
AccessControlcontract. HoldingCREDIT_ROLEon one module grants no authority on another module. No protocol contract overrides_setRoleAdmin, so every role is administered by that contract'sDEFAULT_ADMIN_ROLE.Role Purpose and intended holder DEFAULT_ADMIN_ROLEOpenZeppelin's bytes32(0)root role. Grants and revokes other roles and controls governance-only setters. Held by the governance timelock in production.UPGRADER_ROLEAuthorizes UUPS upgrades. Held by the governance timelock. GUARDIAN_ROLEPauses and unpauses value-moving paths and activates bounded emergency controls. Held by the guardian. MINTER_ROLEAuthorizes both USDfr.mintandUSDfr.burn. Held only byMintRedeemController.CONTROLLER_ROLEAuthorizes reserve deposit and release operations. Held by MintRedeemController.CREDIT_ROLEGrants trusted module-to-module credit primitives, including accounting updates, attestation consumption, loss allocation, and yield minting. Never intended for an EOA. LOSS_BURNER_ROLEAuthorizes MintRedeemController.burnLoss. Separated fromCREDIT_ROLEso the repayment engine cannot combine mint and burn powers. Held byDefaultManagerandReserveManager.FEE_ACCOUNTING_ROLEBrackets fee-neutral junior-capacity changes in sUSDfr. Held by CuratorModule,SGrove, andDefaultManager.COMPLIANCE_ADMIN_ROLEManages user KYC eligibility and jurisdiction blocking, but not protocol exemptions. Held by compliance operations. RESERVE_ADMIN_ROLERatifies bounded custody losses and records physically recovered reserve capital. Held by the governance timelock in production. ORIGINATOR_ROLECreates, amends, and cancels facilities through ClaimBridge, subject to attestation and concentration checks. Held by origination operations.ATTESTER_ROLEIdentifies approved signers in the m-of-n attestation system. It is checked against recovered signatures rather than used as a caller modifier. SERVICER_ROLEExecutes facility funding, repayment distribution, default, acceleration, loss realization, and cure operations, generally against authenticated facts. SETTLEMENT_KEEPER_ROLECalls RedemptionQueue.closeEpochand controls when its bounded liquidity snapshot is taken. Held by a dedicated keeper plus an operational backstop.The former unused
QUEUE_ROLEwas removed. Queue-only vault operations instead authenticate the configured queue address directly, whileSETTLEMENT_KEEPER_ROLEgoverns the economically meaningful epoch-closing operation.Trust Assumptions
State
- Confirmed
Severity
- Severity: Informational
Submitted by
Jiri123
Trust Assumptions
1. Actor and external-system trust assumptions
- A valid governance outcome executed through the Governor and governance timelock is treated as the protocol's trusted administrative root. Compromise or malicious use of sufficient governance authority is outside the smart-contract threat model.
- Privileged actors are trusted to perform the off-chain diligence required by their roles and to use their authority only under the applicable procedures.
- The review assumes adherence to the principle of Segregation of Duties. No single actor is expected to simultaneously hold operational roles intended to remain independent, and collusion between incompatible roles is considered out of scope unless explicitly stated otherwise.
- Attesters are the primary trust boundary for off-chain facts. Once
AttestationOracleaccepts the required signatures, the protocol treats the asserted fact as authoritative and cannot independently verify the underlying servicing, valuation, payment, default, amendment, assignment, or perfection event. - Security therefore assumes that no dishonest or compromised set of current attester keys can satisfy the applicable threshold. Governance is trusted to maintain enough available and independently controlled keys, rotate compromised keys, and configure usable thresholds.
- The operational Guardian is trusted to pause and unpause guarded paths only under the incident procedures. These controls can affect issuance, redemption, transfers, staking, claims, reserve operations, funding, distributions, attestations, and queue settlement. Required compliance exemptions must remain correctly wired so internal loss-burn and cascade legs can execute.
ReserveManager.armReserveLossFreeze()creates a persistent reserve-loss arm with no expiry and closes new senior queue settlement and curator first-loss withdrawals.GUARDIAN_ROLEalone cannot cancel, adjudicate, finalize, or execute the arm; governance or the reserve-loss administrator must perform the applicable terminal or accounting action. Protected exits may remain closed indefinitely if governance does not respond, while queue claims filled before the lock remain claimable.CuratorModule.preArmCustodyFreezeis a separate, time-bounded and contract-budgeted control that governance may cancel or replenish.- Curators are trusted only as to facts the contracts cannot establish. Governance is assumed to approve each curator for the appropriate collateral class after due diligence, and capital presented as first-loss capital is assumed to be legitimately controlled, unencumbered, and intended to bear the disclosed junior risk.
- Curators and users are otherwise untrusted. Slippage, deadline, authorization, sanctions, pause, and accounting checks remain in scope.
- Production is assumed to use the canonical Ethereum-mainnet USDC proxy as the sole reserve token. The protocol treats one USDC as one US dollar and has no independent depeg oracle.
- USDC is assumed to retain six-decimal, non-rebasing, no-fee-on-transfer, and no-transfer-callback behavior. Circle and the USDC administration are assumed not to pause USDC, blacklist protocol-owned addresses required for core operation, or deploy an incompatible upgrade. Depeg, issuer insolvency, blacklist, pause, and incompatible upgrade risks are external asset and availability risks; exact receipt and delivery checks remain in scope.
- The truth and performance of off-chain servicing, physical custody restoration, banking and fiat movements, and legal enforcement are outside the smart-contract review.
2. Intended and accepted protocol behavior
The following behavior is disclosed so it is not misreported as a safety guarantee or trust assumption:
- Senior capital is impairable, and direct redemption may settle below par after available junior protection is applied. Prevention of every senior loss is not a protocol invariant. This statement does not approve any deviation between the implemented direct-exit waterfall and the protocol specification; any such deviation is assessed separately. The
redeem(uint256)overload settles at par or reverts; a holder opts into a sub-par result only through an overload that suppliesminUsdcOut. - Realized facility and adjudicated custody losses apply the configured junior and coverage layers before eligible senior-vault assets. Facility loss realization reverts if the residual exceeds those assets; custody loss recognition may instead record a remaining reserve deficit.
- For direct USDfr redemption, any junior draw is atomic with the exit. When
0 < backing < supply, the target is based on the aggregate book deficit, and pricing uses the junior capital actually delivered. If the deficit reflects a reversible mark, this may crystallize junior capital even if the loss does not later occur. - A shortfall between the idle-USDC ledger and actual custody closes direct mint and redeem until custody is restored or an arm-bound accounting action reduces the ledger.
reconcileIdleUSDC()observes the difference but does not itself write down the ledger.
Direct redemption does not consume the ADR-0033 exit interlock
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jiri123
Description
ADR-0033 §5states that junior and senior exits share one interlock, so neither cohort can escape while the other stays exposed.CuratorModule.withdrawFirstLoss()andRedemptionQueue.closeEpoch()readreserveLossExitsLocked(), butMintRedeemController._redeem()does not, so direct redemption stays open while both other exits are shut. The comment at lineRedemptionQueue.sol:337calls the queue the sole senior exit, which is what §5 was written against.Most locked states are already covered elsewhere, since
supply > backingand a latched deficit both price sub par and a live shortfall reverts in_requireCustodiedReserve(). The state that leaves par reachable is an active arm before any physical shortfall.Recommendation
Correct
ADR-0033 §5and theRedemptionQueuecomment either way.Forest Vault
Accepted as a known limitation of mainnet v1, not remediated in this release.
Cantina Managed
Acknowledged
redeem(uint256 usdfrAmount, uint256 minUsdcOut) has no deadline, contradicting ADR-0034 W
Severity
- Severity: Informational
Submitted by
Jiri123
Description
ADR-0034 Wrequires theredeem()functions to take both a minimum out and a deadline, However, only the three-argument form of theredeem()function matches that requirement. The one-argument form supplies the par floor internally, so it settles at par or reverts and a delay cannot make it settle worse.The two-argument form bounds price, but not time, which is the free option W describes: a caller who names a floor and is not included for an hour lets a searcher hold the transaction until the ratio decays to that floor.
Recommendation
Amend W to record which form is canonical, and point integrators at the three-argument form.
Forest Vault
Fixed in commit ab844dc
Cantina Managed
Fixed in the commit above, by updating
ADR-0034 Waccordingly.Security Review Statement
State
- Confirmed
Severity
- Severity: Informational
Submitted by
Valerian Callens
Security Review Statement
Forest Road engaged Cantina to conduct a security review of the Forest Road Vault. We would like to thank the Forest Road team for their responsiveness and constructive engagement throughout the review process. No significant issues were identified during the assessment, and the protocol is expected to operate as intended.