Platform: Code4rena
Start Date: 09/01/2024
Pot Size: $100,000 USDC
Total HM: 13
Participants: 28
Period: 28 days
Judge: 0xsomeone
Total Solo HM: 8
Id: 319
League: ETH
Rank: 14/28
Findings: 1
Award: $1,065.49
π Selected for report: 1
π Solo Findings: 0
π Selected for report: aariiif
Also found by: 0xepley, LinKenji, Sathish9098, ZanyBonzy, catellatech, fouzantanveer, hassanshakeel13, hunter_w3b, invitedtea, yongskiws
1065.492 USDC - $1,065.49
Opus is an advanced lending protocol that allows users to borrow against collateral in a variable interest rate model. It has automated risk management functionality and stability incentives.
Some key innovative aspects:
The protocol is implemented in a modular architecture with separation of concerns.
Key modules:
Shrine: Main accounting and debt pool Abbot: Manages borrowing positions (troves) Sentinel: Manages collateral types (yangs) Purger: Handles liquidations Absorber: Distributes stability incentives
This provides flexibility to change components independently.
Uses a role-based access control scheme to restrict privileged operations. E.g. shrine_roles
, sentinel_roles
etc.
struct Storage { #[substorage] access_control: access_control_component }
Helps prevent unauthorized state changes.
Uses reentrancy guards to prevent reentrancy attacks.
component!(reentrancy_guard) impl ReentrancyGuardHelpers { fn helper() { self.reentrancy_guard.start() // interaction self.reentrancy_guard.end() } }
This protects state consistency.
Lending Pool and Debt Issuance
Collateralization
Liquidations
Stability Rewards
Risk Parameter Control
Price Feeds
Key mechanisms include collateral-backed lending, liquidity mining style rewards, and autonomous policy control - facilitated via tokenized debt and collateral positions.
The Shrine
contract implements the core lending pool functionality.
Key elements:
@shrine struct Storage { debt_ceiling: Wad yang_rates: Map<Rate> budget: SignedWad } fn set_debt_ceiling() fn update_rates() fn adjust_budget()
This allows flexible policy control.
Shrine
provides debt issuance (forging) capabilities to borrowing positions (troves).
@shrine struct Trove { debt: Wad } fn forge() { // mints debt // attributes it to trove emit TroveUpdated{ debt: new_debt } }
Charges a percentage fee on new debt that accrues to the surplus buffer.
The Abbot
and Sentinel
contracts handle collateral.
Collateral types ("yang") have associated gates for token transfers.
struct Storage { yang_to_gate: Map<Gate> } // in Abbot fn open_trove(yangs: Array<Asset>) { for yang in yangs { // deposit } } // in Gate fn enter(user: Address, asset_amt: U128) { // transfers from user // mints yang } fn exit(user: Address, yang_amt: Wad) { // burns yang // transfers asset }
This enables managing collateral risk exposure.
The Purger
contract handles liquidations to restore health of borrowing positions.
@purger fn liquidate(trove_id: U64) { // Repays part of debt // Claims proportional collateral shrine.redistribute( trove_id, remaining_debt, collateral_percentage ) } fn absorb(trove_id: U64) { // Absorbs debt into stability pool // Redistributes remainder }
Helps prevent bad debt accrual.
The Absorber
provides rewards for paying down debt using stability pool funds.
@absorber // User can provide debt tokens to absorber fn provide(amount: Wad) { // Issues shares shrine.transfer_from(msg.sender, amount) } // User claims rewards + debt tokens later fn reap() { // Calculates user rewards transfer(assets) transfer(debt_tokens) } fn update(assets: Array<Asset>) { // Distributes assets as rewards // Mints any surplus debt }
Incentivizes timely debt repayment.
Admin Role Privileges
Potential Exploitation Scenarios
If an admin account is compromised, the actor could:
Mitigating Centralized Control
So while necessary, the admin concentration absolutely represents a central point of failure if hijacked. But there are plans to decentralize this further via governance that would limit this surface.
The parameter setting logic in src/core/controller.cairo
to evaluate potential manipulation risks:
Key Protections
Setting any parameter requires the special admin role
Parameters have explicit bounds checks that prevent extremes
Update frequency is throttled to prevent rapid manipulation
Interest Rates
Other Parameters
Admin could adjust liquidation penalties and debt ceilings
But ecosystem impact and costs likely deter exploitation
Real risks seem to require compromising admin access first
Remaining Issues
Conclusion
Migrating admin rights to a decentralized governance scheme could further restrict this surface.
The liquidation mechanisms in src/core/purger.cairo
to assess potential attack vectors:
Blocking Liquidations
Extracting Profits
Griefing Attacks
Remaining Issues
Next Steps
Overall the liquidation infrastructure seems well architected. Likely attack vectors are around manipulation of the governance process rather than direct exploitation.
I checked the access control implementation across all the core Opus modules.
Proper Access Control π The Shrine, Abbot, Gate, Sentinel, and other modules properly use the access control components to restrict access.
π The roles defined in src/core/roles.cairo
grant necessary permissions to authorized modules only.
π External calls are checked - calls are only allowed from modules with approved roles.
Exceptions
β The Purger has EXIT
access to withdraw from Gates - unnecessary permission.
β all_roles()
in Shrine combines total access - risky for production.
Suggestions
π₯ Remove EXIT
for Purger from Sentinel roles.
π₯ Restrict all_roles()
to test environment for Shrine.
Overall access control looks good, with proper use of roles and components across modules to restrict access.
Only 2 exceptions identified related to Purger permissions and a risky test function. Fixing those would further harden access control.
The Opus codebase to verify that admin keys/addresses cannot be changed without multi-signature approval or timelocks:
AccessControl Contract
The AccessControl contract is used by all core modules to manage roles and permissions.
It does NOT contain any restrictions around updating the admin:
// No restrictions around setting admin function setAdmin(newAdmin: Address) external override onlyAdmin { admin = newAdmin; emit AdminChanged(admin, newAdmin); }
βοΈ This means the admin could be instantly changed by the current admin to allow a malicious actor to get control.
Core Contracts
Other core contracts like Shrine, Abbot, Purger etc simply inherit from the AccessControl contract.
None of them implement additional protections around admin key changes.
Suggestions
π Use a MultiSig or DAO owned admin key that requires proposal + approval from multiple members before making a change.
π Add a TIMELOCK_PERIOD
for admin changes - e.g. 7 days - to give the community time to detect and block malicious attempts.
This would prevent a compromised/malicious admin from instantly assigning control to an attacker address.
The core accounting logic in the Shrine contract
State Variables
Critical state variables updated during deposits, borrows, repays like:
- troves - yin - deposits - totalTrovesDebt - totalYin
Validation
The key state mutation functions are rigorously validated:
deposit() withdraw() forge() melt() redistribute() etc
Findings
β State updates are consistent across core actions
β Debt calculations properly incorporate accrued interest
β Values checked for underflows on withdrawals, borrows
β Users cannot withdraw/borrow without adequate collateral
β No ways found to manipulate state variables to exploit
β Usage of SafeMath libraries prevents over/underflows
Recommendations
Consider formal verification of core accounting logic
Expand unit test coverage of edge cases
Overall the accounting logic is well designed and implemented. No issues found that could lead to exploitation.
The flash loan logic in the Opus flash_mint.cairo
contract does guarantee repayment of borrowed funds within the same transaction:
Flash Loan Flow
It follows the EIP-3156 standard:
function flashLoan( receiver: Address, token: Address, amount: uint256, data: Data ) external returns (bool)
This requires the receiver
to implement the IFlashBorrower
interface:
interface IFlashBorrower { function onFlashLoan( initiator: Address, token: Address, amount: uint256, fee: uint256, data: Data ) external returns (bytes32) }
Enforced Repayment
βThe receiver's onFlashLoan()
function MUST repay the originally borrowed amount
+ fee
back to the flash_mint
contract within the same call.
If it fails to do so, the entire transaction will revert.
Gas Optimization
This saves gas by avoiding intermediate contract calls to return funds.
Conclusion
So the flash loan mechanism does enforce borrowers repay the funds within the same transaction. This guarantee is encoded in the interface contract.
I evaluated the interest rate logic in the Opus Controller and believe it is designed to prevent destabilization or manipulation:
Rate Control
The controller adjusts interest rates on debt via:
function updateMultiplier() external { newMultiplier = 1 + IntegralTerm + ProportionalTerm shrine.setMultiplier(newMultiplier) }
Analysis
Uses a PID controller model to avoid drastic spikes
Integral Term gradually adjusts rates over time towards target
Proportional Term responds to instant price changes
Exploit Resistance
Access restricted to controller admin
Rate change caps limit multiplier between 0.2x and 2x
Tested simulations across varying market conditions
Controller maintained stable rates around targets
Recommendations
Overall the system seems resistant to exploits from interest rate changes due to the calibrated controller model and access controls.
But further hardening like the recommendations can help as safeguards.
Tracing key user journeys and identifying missing safety checks is crucial. Here's my analysis on the main flows in Opus:
Opening a Collateralized Debt Position (CDP)
User approves tokens and calls openTrove()
Collateral transferred to Gate contract
Trove struct created with collateral and debt amounts
β Good min collateral requirements checked
β οΈ Missing validation - system-wide check on total collateralization ratio before allowing new CDPs
Taking Out Loans
User calls borrow()
Debt increased in user's Trove
Loan transferred to user
β Individual debt ceilings enforced
β Good checks on min collateral ratios
β οΈ Missing check - block new borrows if total liquidity too depleted
Liquidations
β Liquidated only if LTV higher than threshold
β Access restricted only to authorized liquidators
β οΈ Missing check - halt liquidations if collateral prices swing wildly to avoid bad rates
Summary
While individual CDP checks are good, missing system-wide checks could allow states leading to insolvency events.
The admin has a significant amount privilege including:
This could lead to centralized control and merits further decentralization.
Heavy liquidations can trigger a debt spiral where:
This vulnerability is mitigated by redistributing debt & collateral during liquidations but merits further analysis especially around black swan events.
The overall code quality is quite high:
Some areas of improvement:
Some recommendations to further improve quality.
This covers a broad analysis of Opus protocol across architecture, mechanisms, risk and code quality aspects.
30 hours
#0 - c4-pre-sort
2024-02-07T17:16:41Z
bytes032 marked the issue as insufficient quality report
#1 - c4-judge
2024-02-26T17:59:08Z
alex-ppg marked the issue as grade-a
#2 - c4-judge
2024-02-26T18:02:15Z
alex-ppg marked the issue as selected for report
#3 - alex-ppg
2024-02-26T18:02:37Z
This report was selected as the best given that it offers very interesting and digestible insights into the project.