Platform: Code4rena
Start Date: 11/12/2023
Pot Size: $90,500 USDC
Total HM: 29
Participants: 127
Period: 17 days
Judge: TrungOre
Total Solo HM: 4
Id: 310
League: ETH
Rank: 15/127
Findings: 2
Award: $1,181.30
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: SBSecurity
Also found by: 0xaltego, 0xbepresent, Aymen0909, Bauchibred, Cosine, EVDoc, EloiManuel, HighDuty, Sathish9098, Tendency, Timeless, ZanyBonzy, beber89, deliriusz, ether_sky, grearlake, hals, klau5, lsaudit, nadin, rvierdiiev, tsvetanovv
20.8157 USDC - $20.82
POLL_DURATION_BLOCKS
cause problems in other chainsAs per protocol docs its also possible to deploy in Arbitrum
Deployment: we anticipate to launch on Ethereum mainnet & L2s like Arbitrum.
Ethereum Mainnet: The average block time on Ethereum mainnet has historically been around 13-15 seconds. Using 13 seconds as an average is a common approximation for Ethereum mainnet.
Arbitrum (Layer 2): Layer 2 solutions, like Arbitrum, often have different block times compared to Ethereum mainnet. Arbitrum, for instance, doesn't have a fixed block time and can be significantly faster due to its off-chain processing before batches are submitted to Ethereum.
Given these differences, the hardcoded value of POLL_DURATION_BLOCKS
based on a 13-second
block time may not be appropriate for all networks where the contract will be deployed:
On Ethereum mainnet, the current implementation is accurate.
On Arbitrum or similar Layer 2 networks, the block time can vary and may not align with the 13-second
average. Therefore, the duration of polls in terms of real-time could be significantly different from the intended 7 days.
FILE: 2023-12-ethereumcreditguild/src/governance/LendingTermOffboarding.sol 36: uint256 public constant POLL_DURATION_BLOCKS = 46523; // ~7 days @ 13s/block
Consider implementing a mechanism to dynamically adjust POLL_DURATION_BLOCKS based on the actual block time of the network where the contract is deployed. This could be done via an oracle or other means to fetch the average block time for the network.
The function forgive() lacks sufficient access control measures, making it potentially exploitable. Any external caller can trigger the forgive() function without adequate validation of their authority or role. This oversight could lead to unauthorized closure of auctions and forgiveness of loans, impacting the protocol's financial integrity.
FILE: 2023-12-ethereumcreditguild/src/loan/AuctionHouse.sol /// @notice forgive a loan, by marking the debt as a total loss /// @dev this is meant to be used when an auction concludes without anyone bidding, /// even if 0 CREDIT is asked in return. This situation could arise /// if collateral assets are frozen within the lending term contract. function forgive(bytes32 loanId) external { // this view function will revert if the auction is not started, // or if the auction is already ended. (, uint256 creditAsked) = getBidDetail(loanId); require(creditAsked == 0, "AuctionHouse: ongoing auction"); // close the auction in state auctions[loanId].endTime = block.timestamp; nAuctionsInProgress--; // notify LendingTerm of auction result address _lendingTerm = auctions[loanId].lendingTerm; LendingTerm(_lendingTerm).onBid( loanId, msg.sender, 0, // collateralToBorrower 0, // collateralToBidder 0 // creditFromBidder ); // emit event emit AuctionEnd( block.timestamp, loanId, LendingTerm(_lendingTerm).collateralToken(), 0, // collateralSold 0 // debtRecovered ); }
Add access control like CoreRoles.GOVERNOR
The check loan.callTime == block.timestamp assumes that the loan call and the auction start transaction occur in the same block. In reality, due to network congestion or transaction ordering, these two events could be processed in different blocks, even if the transactions are broadcast back-to-back by the user.
If the auction start transaction does not get included in the same block as the loan call (for reasons like gas price competition, block fullness, etc.), it will fail. This could lead to increased transaction costs and operational delays.
FILE: 2023-12-ethereumcreditguild/src/loan/AuctionHouse.sol require( loan.callTime == block.timestamp, "AuctionHouse: loan previously called" );
Instead of requiring the auction to start in the same block as the loan call, allow a reasonable time buffer (e.g., a few blocks or a fixed time period like 15 minutes).
Where BUFFER_PERIOD is the permissible time delay.
+ require( + loan.callTime + BUFFER_PERIOD >= block.timestamp, + "AuctionHouse: loan previously called" + );
The primary risk here is that any entity with the GAUGE_PNL_NOTIFIER role can initiate an auction for any loan, regardless of its association with that specific loan.
If the GAUGE_PNL_NOTIFIER role is not tightly controlled or if it's assigned to entities that manage multiple loans or different aspects of the system, there's a risk of auctions being initiated inappropriately, either accidentally or maliciously.
FILE: 2023-12-ethereumcreditguild/src/loan/AuctionHouse.sol function startAuction(bytes32 loanId, uint256 callDebt) external { // check that caller is a lending term that still has PnL reporting role require( core().hasRole(CoreRoles.GAUGE_PNL_NOTIFIER, msg.sender), "AuctionHouse: invalid caller" ); // check the loan exists in calling lending term and has been called in the current block LendingTerm.Loan memory loan = LendingTerm(msg.sender).getLoan(loanId); require( loan.callTime == block.timestamp, "AuctionHouse: loan previously called" ); // check auction for this loan has not already been created require( auctions[loanId].startTime == 0, "AuctionHouse: auction exists" ); // save auction in state auctions[loanId] = Auction({ startTime: block.timestamp, endTime: 0, lendingTerm: msg.sender, collateralAmount: loan.collateralAmount, callDebt: callDebt }); nAuctionsInProgress++; // emit event emit AuctionStart( block.timestamp, loanId, LendingTerm(msg.sender).collateralToken(), loan.collateralAmount, callDebt ); }
_borrow
functionThe function could reject the creation of a new loan if it detects that the generated loan ID already exists, even though it's a new loan request.
If the same borrower requests multiple loans within the same block, there is a theoretical risk of generating the same loan ID. This risk arises because block.timestamp remains constant for all transactions within a single block.
In blockchain transactions, the block timestamp doesn't change from transaction to transaction within the same block. Hence, if a borrower initiates multiple loan requests in transactions that are included in the same block, the only differing factor in the hash function would be the borrower address, which remains the same.
Even a small chance of ID collision could pose a significant risk in financial contexts, as it affects the integrity and reliability of the loan management system.
FILE: 2023-12-ethereumcreditguild/src/loan /LendingTerm.sol loanId = keccak256( abi.encode(borrower, address(this), block.timestamp) );
A potential Denial-of-Service (DoS) attack on the lending platform, specifically targeting the borrow function in conjunction with the hardcap limit.
The borrow function allows a borrower to take out a loan, provided the total amount borrowed (_postBorrowIssuance) does not exceed the platform's hardcap (params.hardCap). There is no maximum limit for
An attacker (or a legitimate borrower with enough resources) could potentially borrow an amount that brings the total issuance right up to, or very close to, the hardcap. This would be done by submitting a borrow transaction with a high borrowAmount that is just below the remaining space under the hardcap.
Once the hardcap is nearly reached or reached, any pending transactions in the mempool that also attempt to borrow funds would fail. These transactions would be denied service because they would cause the total borrowed amount to exceed the hardcap, thus failing the check _postBorrowIssuance <= params.hardCap.
Borrowers with pending transactions (who may have submitted their requests before the attacker but with lower gas fees) find their transactions failing. The platform effectively experiences a DoS situation for new borrow requests until the total borrowed amount falls below the hardcap again.
FILE: 2023-12-ethereumcreditguild/src/loan/LendingTerm.sol /// @notice initiate a new loan function _borrow( address borrower, uint256 borrowAmount, uint256 collateralAmount ) internal returns (bytes32 loanId) { require(borrowAmount != 0, "LendingTerm: cannot borrow 0"); require(collateralAmount != 0, "LendingTerm: cannot stake 0"); loanId = keccak256( abi.encode(borrower, address(this), block.timestamp) ); // check that the loan doesn't already exist require(loans[loanId].borrowTime == 0, "LendingTerm: loan exists"); // check that enough collateral is provided uint256 creditMultiplier = ProfitManager(refs.profitManager) .creditMultiplier(); uint256 maxBorrow = (collateralAmount * params.maxDebtPerCollateralToken) / creditMultiplier; require( borrowAmount <= maxBorrow, "LendingTerm: not enough collateral" ); // check that enough CREDIT is borrowed require( borrowAmount >= ProfitManager(refs.profitManager).minBorrow(), "LendingTerm: borrow amount too low" ); // check the hardcap uint256 _issuance = issuance; uint256 _postBorrowIssuance = _issuance + borrowAmount; require( _postBorrowIssuance <= params.hardCap, "LendingTerm: hardcap reached" ); // check the debt ceiling uint256 totalBorrowedCredit = ProfitManager(refs.profitManager) .totalBorrowedCredit(); uint256 gaugeWeightTolerance = ProfitManager(refs.profitManager) .gaugeWeightTolerance(); uint256 _debtCeiling = (GuildToken(refs.guildToken) .calculateGaugeAllocation( address(this), totalBorrowedCredit + borrowAmount ) * gaugeWeightTolerance) / 1e18; if (totalBorrowedCredit == 0) { // if the lending term is deprecated, `calculateGaugeAllocation` will return 0, and the borrow // should revert because the debt ceiling is reached (no borrows should be allowed anymore). // first borrow in the system does not check proportions of issuance, just that the term is not deprecated. require(_debtCeiling != 0, "LendingTerm: debt ceiling reached"); } else { require( _postBorrowIssuance <= _debtCeiling, "LendingTerm: debt ceiling reached" ); } // save loan in state loans[loanId] = Loan({ borrower: borrower, borrowTime: block.timestamp, borrowAmount: borrowAmount, borrowCreditMultiplier: creditMultiplier, collateralAmount: collateralAmount, caller: address(0), callTime: 0, callDebt: 0, closeTime: 0 }); issuance = _postBorrowIssuance; if (params.maxDelayBetweenPartialRepay != 0) { lastPartialRepay[loanId] = block.timestamp; } // mint debt to the borrower RateLimitedMinter(refs.creditMinter).mint(borrower, borrowAmount); // pull the collateral from the borrower IERC20(params.collateralToken).safeTransferFrom( borrower, address(this), collateralAmount ); // emit event emit LoanOpen( block.timestamp, loanId, borrower, collateralAmount, borrowAmount ); }
Add Maximum allowed borrow limits
When the creditMultiplier is decreased, the effective value of each CREDIT token is reduced. This means that borrowers will owe more CREDIT tokens than they initially borrowed to account for the same value.
If the value of the collateral held against the loan does not increase in line with the increase in debt due to the lowering of the creditMultiplier, the loan could become undercollateralized.
This situation poses a risk of liquidation if the collateral value falls below certain thresholds, adding further financial pressure on the borrower.
Initial Scenario:
A borrower takes a loan of 1,000 CREDIT. The creditMultiplier is 1.0 (or in contract terms, 1e18). System Faces Losses:
To address these losses, the creditMultiplier is reduced to 0.8 (0.8e18 in contract terms). Effect on the Loan:
Originally, 1 CREDIT = 1 unit of value (since creditMultiplier was 1.0). After the decrease, 1 CREDIT = 0.8 units of value (since creditMultiplier is now 0.8). This means each CREDIT token now represents less value. Recalculating Debt:
The borrower's debt needs to be recalculated to reflect the true value they borrowed. Initially, 1,000 CREDIT represented 1,000 units of value. Now, to represent the same 1,000 units of value, more CREDIT tokens are needed. The new debt becomes 1,250 CREDIT. This is because each CREDIT token is now worth only 0.8 units of value, so more tokens are required to sum up to the original 1,000 units of value. Impact on Borrower:
The borrower now owes more CREDIT tokens than they initially borrowed. They need to repay 1,250 CREDIT instead of the original 1,000 CREDIT to account for the decrease in value per CREDIT token.
FILE: 2023-12-ethereumcreditguild/src/loan/LendingTerm.sol uint256 creditMultiplier = ProfitManager(refs.profitManager) .creditMultiplier(); uint256 principal = (borrowAmount * loan.borrowCreditMultiplier) / creditMultiplier;
call
and callMany
functions visibilityBoth functions are working same way. But there is the Inconsistancy between call
and callMany
.
call Function
: This function is marked as external, meaning it can only be called from outside the contract. This is typical for functions that are meant to be part of the contract's interface with the outside world, like user interactions. The external visibility is more gas-efficient for functions that will only be called externally.
callMany Function
: On the other hand, the callMany function is marked as public, indicating that it can be called both internally (from within the contract itself) or externally (like from another contract or an EOA - Externally Owned Account). Making a function public is useful when the functionality needs to be accessible from both within and outside the contract.
Security Considerations
: Having public functions can potentially introduce security risks if not handled correctly, especially if they can change important state variables or trigger critical functionalities. It’s crucial to ensure proper access control and checks are in place.
FILE: 2023-12-ethereumcreditguild/src/loan/LendingTerm.sol function call(bytes32 loanId) external { function callMany(bytes32[] memory loanIds) public {
Consider the same visibility for both functions
An opportunistic bidder observes the auction and waits until it reaches this final phase. Once the auction is in this phase, they can bid and claim the entire collateral without paying any CREDIT. This results in the protocol losing valuable collateral without recovering any portion of the defaulted loan, while the bidder gains the collateral for free.
This situation creates an unfair advantage for late bidders and a significant risk for the protocol, as it could lose valuable collateral without adequate compensation.
FILE: 2023-12-ethereumcreditguild/src/loan/AuctionHouse.sol else { // receive the full collateral collateralReceived = auctions[loanId].collateralAmount; //creditAsked = 0; // implicit }
Loan Default and Auction Initiation
:
A borrower defaults on their loan, or specific conditions are met (e.g., missed partial repayments), leading to the loan being called. The startAuction function is invoked for this loan, setting up an auction with a startTime.
Auction Phases
:
The auction goes through two main phases: First Half (Increasing Collateral Offering): Initially, an increasing portion of the collateral is offered for the full debt amount. This continues until the midPoint of the auction duration. Second Half (Decreasing Debt Asking): After the midPoint, the full collateral is offered, but the amount of CREDIT asked for decreases over time.
Auction Conclusion Phase
:
If the auction reaches the end of its duration (block.timestamp >= _startTime + auctionDuration) without any successful bids, the final phase is triggered. In this phase, the getBidDetail function specifies that the full collateral can be claimed for 0 CREDIT. This is a critical point where the arbitrage opportunity arises.
quorum
variable not capped with reasonable valuesThe quorum is not capped, which could lead to governance issues. Extremely high or low values can affect the offboarding process's integrity
FILE : 2023-12-ethereumcreditguild/src/governance/LendingTermOffboarding.sol /// @notice set the quorum for offboard votes function setQuorum( uint256 _quorum ) external onlyCoreRole(CoreRoles.GOVERNOR) { emit QuorumUpdated(quorum, _quorum); quorum = _quorum; }
Implement min
and max
value checks
setMinBorrow() and setGaugeWeightTolerance() does not check if input parameter is the same as the current state of the variable which is being updated. If function is being called with the same value as the current state - even though there's no change - function will success and emit an event that the change has been made.
FILE: 2023-12-ethereumcreditguild/src/governance/ProfitManager.sol /// @notice set the minimum borrow amount function setMinBorrow( uint256 newValue ) external onlyCoreRole(CoreRoles.GOVERNOR) { _minBorrow = newValue; emit MinBorrowUpdate(block.timestamp, newValue); } /// @notice set the gauge weight tolerance function setGaugeWeightTolerance( uint256 newValue ) external onlyCoreRole(CoreRoles.GOVERNOR) { gaugeWeightTolerance = newValue; emit GaugeWeightToleranceUpdate(block.timestamp, newValue); }
Add same value checks
require(newValue!=_minBorrow ); require(newValue!=gaugeWeightTolerance );
Excessive collateral for a single loan can skew the overall risk profile of the lending pool. In typical lending scenarios, loans are diversified to spread risk. However, if one or more loans are over-collateralized, it can lead to a concentration of risk, making the pool more vulnerable to market shifts affecting those specific collaterals.
In a scenario where the collateral type is a less liquid or easily manipulated asset, borrowers could artificially inflate the value of their collateral, borrow heavily against it, and subsequently reduce the collateral value, affecting the platform's financial stability.
FILE: 2023-12-ethereumcreditguild/src/loan/LendingTerm.sol /// @notice add collateral on an open loan. /// a borrower might want to add collateral so that his position does not go underwater due to /// interests growing up over time. function _addCollateral( address borrower, bytes32 loanId, uint256 collateralToAdd ) internal { require(collateralToAdd != 0, "LendingTerm: cannot add 0"); Loan storage loan = loans[loanId]; // check the loan is open require(loan.borrowTime != 0, "LendingTerm: loan not found"); require(loan.closeTime == 0, "LendingTerm: loan closed"); require(loan.callTime == 0, "LendingTerm: loan called"); // update loan in state loans[loanId].collateralAmount += collateralToAdd; // pull the collateral from the borrower IERC20(params.collateralToken).safeTransferFrom( borrower, address(this), collateralToAdd ); // emit event emit LoanAddCollateral( block.timestamp, loanId, borrower, collateralToAdd ); }
FILE: 2023-12-ethereumcreditguild/src/loan/AuctionHouse.sol 159: //creditAsked = 0; // implicit
#0 - 0xSorryNotSorry
2024-01-05T18:55:03Z
[L-1] Hardcoded POLL_DURATION_BLOCKS cause problems in other chains -> dup of #816
#1 - c4-pre-sort
2024-01-05T18:55:09Z
0xSorryNotSorry marked the issue as sufficient quality report
#2 - Trumpero
2024-01-30T23:02:35Z
L-1: low (dup of #816) L-2: low L-3: low L-4: info L-5: info L-6: low L-7: info L-8: NC L-9: info L-10: NC L-11: NC L-12: invalid, maxDebtPerCollateral is used for borrowing, not collateral price. NC-1: NC
#3 - c4-judge
2024-01-31T11:56:54Z
Trumpero marked the issue as grade-b
🌟 Selected for report: Sathish9098
Also found by: 0xSmartContract, 14si2o_Flint, JayShreeRAM, Myd, SBSecurity, beber89, hunter_w3b, invitedtea, pavankv
1160.4822 USDC - $1,160.48
The Ethereum Credit Guild is a DeFi protocol featuring a dual-token system: GUILD for governance and gUSDC as a debt token.
Smart Contracts
: Utilizes contracts like LendingTerm for loan conditions, and AuctionHouse for asset liquidation.
Gauge Voting System
: Allows GUILD holders to vote on credit limits and loan terms, aligning governance with financial outcomes.
Token Mechanics
: GUILD tokens are minted via staking gUSDC, with functionalities centered around protocol governance.
Risk and Debt Management
: Implements strategies for managing bad debt and liquidation, adjustable through governance votes.
Decentralized Governance
: Employs a governance model allowing token holders to make key protocol decisions, ensuring community-driven management.
function forgive(bytes32 loanId) external { // this view function will revert if the auction is not started, // or if the auction is already ended. (, uint256 creditAsked) = getBidDetail(loanId); require(creditAsked == 0, "AuctionHouse: ongoing auction"); // close the auction in state auctions[loanId].endTime = block.timestamp; nAuctionsInProgress--; // notify LendingTerm of auction result address _lendingTerm = auctions[loanId].lendingTerm; LendingTerm(_lendingTerm).onBid( loanId, msg.sender, 0, // collateralToBorrower 0, // collateralToBidder 0 // creditFromBidder ); // emit event emit AuctionEnd( block.timestamp, loanId, LendingTerm(_lendingTerm).collateralToken(), 0, // collateralSold 0 // debtRecovered ); }
Debt Obligation Erasure
: The borrower’s debt is erased without any repayment or collateral recovery, leading to a direct financial loss for the protocol.
CreditMultiplier Impact
: In protocols where a creditMultiplier affects loan calculations, such losses might necessitate adjustments to this multiplier, impacting all borrowers and the tokenomics.
Financial Health
: Accumulation of such losses can strain the protocol's financial reserves.
Risk Management
: It highlights potential gaps in the risk assessment framework, particularly in collateral valuation and liquidity.
Tokenomics and Inflationary Pressure
:
Suppose a borrower has a loan of 5,000 CREDIT against a certain crypto-asset as collateral. A significant market event renders the asset worthless. The auction fails to attract bidders, leading to the invocation of forgive.
Loan-to-Value (LTV) Ratio Volatility
:
Under-collateralization and Liquidations
:
Impact on Borrower's Position
:
Market Sentiment
:
Inflationary/Deflationary Pressures
:
Bid Dynamics
:
The mechanism for staking gUSDC to mint GUILD, and the reverse process, might be influenced by the changing values of these tokens. A decline in GUILD's value, for instance, could reduce the incentive for users to stake gUSDC.
Precision and Rounding Errors
: Complex rebasing calculations could lead to precision loss or rounding errors, affecting token balances.
Rebasing Side Effects
: Unintended side effects of rebasing on user balances, particularly if user interactions occur during the rebasing process.
Liquidation Logic Flaws
: Errors in implementing the liquidation logic could lead to inefficient or incorrect asset liquidation.
Smart Contract Interactions
: Vulnerabilities in how AuctionHouse interacts with other contracts, like token contracts or external DeFi platforms.
Loan Parameters Handling
: Inaccuracies in setting or modifying loan parameters (interest rates, collateral ratios) could affect lending operations.
Loan Lifecycle Management
: Bugs in the management of loan states (active, defaulted, liquidated) could result in incorrect system behavior.
Parameter Adjustment Vulnerabilities
: Mismanagement or errors in adjusting critical protocol parameters could destabilize the system.
Reentrancy Attacks
: Functions that transfer funds or interact with external contracts could be vulnerable to reentrancy attacks.
Gas Limitations and Inefficiencies
: Functions, particularly those with complex logic, might consume excessive gas, leading to high transaction costs or failures.
Inter-Contract Dependency Risks
: Given the interconnected nature of the contracts, a bug or failure in one contract (e.g., ERC20RebaseDistributor) could adversely affect others (like LendingTerm).
Front-Running in AuctionHouse
: Potential for front-running in auction bids, where miners or other users could exploit the transaction ordering for their benefit.
Inter-Contract Financial Calculations
: Any financial calculations in other contracts (like yield farming rewards, fee distributions, etc.) relying on token balances might yield incorrect results post-rebase.
Consistency in Asset Valuation
: The value of assets transferred during the auction needs to be consistent with their valuation in other parts of the system. Discrepancies here can affect the protocol's financial accuracy.
Collateralization and Loan Agreements
: In lending scenarios, where the rebased token might be used as collateral, changes in token quantity could affect collateralization ratios. This might inadvertently trigger liquidations or alter loan terms.
Liquidity Pools and DeFi Protocols
: If the rebased token is used in liquidity pools or other DeFi protocols, a rebase event can change the value or quantity of tokens in these pools. This could lead to issues like imbalanced pools, affecting staking, lending, or swapping operations.
Rebase Impact on Other Contracts
The rebasing mechanism could have unintended impacts on contracts that interact with the rebased token. For example, if other contracts are not designed to handle rebasing tokens correctly, users’ balances or contract states might become inconsistent after a rebase event.
Governance Decisions Propagation
: Decisions made through the governance process, particularly by the GOVERNOR, might involve changes in multiple contracts. Ensuring that these changes are synchronized and do not create conflicts or inconsistencies is a significant integration concern.
Role Changes and Access Control
: Adjustments in roles or permissions must be propagated correctly across all affected contracts to avoid access control breaches or functional discrepancies.
Data Synchronization
: Ensuring that all relevant contracts have up-to-date and synchronized information regarding loan terms, active loans, and borrower details is crucial.
// Initial roles setup: direct hierarchy, everything under governor _setRoleAdmin(CoreRoles.GOVERNOR, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUARDIAN, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.RATE_LIMITED_CREDIT_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUILD_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.RATE_LIMITED_GUILD_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_ADD, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_REMOVE, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_PNL_NOTIFIER, CoreRoles.GOVERNOR); _setRoleAdmin( CoreRoles.GUILD_GOVERNANCE_PARAMETERS, CoreRoles.GOVERNOR ); _setRoleAdmin( CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, CoreRoles.GOVERNOR ); _setRoleAdmin( CoreRoles.CREDIT_GOVERNANCE_PARAMETERS, CoreRoles.GOVERNOR ); _setRoleAdmin(CoreRoles.CREDIT_REBASE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_PROPOSER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_EXECUTOR, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_CANCELLER, CoreRoles.GOVERNOR);
Sets up a role-based access control system in a Ethereum Credit Guild, with a strong centralization of power under the GOVERNOR role
The GOVERNOR
has administrative power over all roles, potentially leading to a single point of control and decision-making. This centralization can be efficient for management but poses risks of abuse or mismanagement.
Centralization of control makes the system vulnerable to the decisions or actions of the GOVERNOR. If the GOVERNOR acts maliciously or is compromised, the entire protocol could be at risk.
With the GOVERNOR controlling all key roles, including those related to minting tokens (CREDIT_MINTER, GUILD_MINTER), managing gauges (GAUGE_ADD, GAUGE_REMOVE, GAUGE_PARAMETERS), and executing time-locked transactions (TIMELOCK_PROPOSER, TIMELOCK_EXECUTOR, TIMELOCK_CANCELLER), there's a risk that this power could be used in ways that are not in the best interest of the protocol or its users.
Challenges in Protocol Evolution
: As the protocol grows and evolves, the centralized GOVERNOR structure might become a bottleneck, hindering the adoption of changes and updates that require a broader consensus.
Potential for Governance Gridlock
: Over time, the concentration of power might lead to governance gridlock if the GOVERNOR becomes resistant to changes or if there’s a conflict of interest within the community.
I analyzed the architecture and business logic of abstract contract ERC20RebaseDistributor
and two important contracts: AuctionHouse
and LendingTerm
. This analysis focuses on their structures and operational mechanics.
The ERC20RebaseDistributor contract, designed for a rebase mechanism in a DeFi protocol, exhibits several key implementations,
Rebase Functionality
: It allows token supply adjustments (rebasing), proportionally affecting the balance of all token holders who opt into rebasing.
Opt-in Mechanism
: Users can choose to participate in rebasing through enterRebase() and opt out using exitRebase(), providing flexibility in how they wish to interact with the token's economics.
Token Burn for Distribution
: The contract enables users to burn their tokens to distribute value to all rebasing token holders, enhancing the token's utility as a distribution mechanism.
Share-Based Accounting
: Internally, the contract converts user balances to shares when they opt into rebasing. This approach simplifies the calculation of individual holdings post-rebase.
Dynamic Share Price Calculation
: The share price is recalculated upon each distribution, ensuring that the value of rebasing shares accurately reflects the new total supply.
Interpolation for Distribution
: The contract uses a linear interpolation over a set period (30 days) to gradually distribute the value of burned tokens, preventing sudden supply shocks.
Safeguards Against Manipulation
: The contract highlights the need for a meaningful initial balance in rebasing to prevent share price manipulation, echoing concerns from historical DeFi exploits.
The AuctionHouse contract in a DeFi protocol typically manages the auctioning of assets, often used for liquidating collateral from defaulted loans or for other decentralized governance purposes. It allows users to place bids on assets, handles the logic for determining winning bids, and ensures the transfer of both assets and payment according to the auction rules. The contract is designed to be transparent, fair, and efficient, providing a decentralized mechanism for asset liquidation or sales within the ecosystem
The LendingTerm contract in a DeFi protocol is designed to define and manage the terms of lending operations.
Setting Loan Parameters
: It specifies the conditions for loans, such as interest rates, collateral requirements, maximum debt per collateral, and repayment schedules.
Loan Origination
: Facilitates the creation of new loans under the specified terms, handling collateralization and issuance of debt.
Repayment and Liquidation
: Manages loan repayments and enforces liquidation in case of defaults, ensuring compliance with the set terms.
Governance Integration
: Often integrated with governance mechanisms for modifying terms or adding new ones, reflecting the decentralized decision-making process.
File | % Lines | % Statements | % Branches | % Funcs |
---|---|---|---|---|
src/core/Core.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 100.00% (1/1) |
src/core/CoreRef.sol | 100.00% (15/15) | 100.00% (20/20) | 100.00% (2/2) | 100.00% (6/6) |
src/governance/GuildGovernor.sol | 92.86% (13/14) | 92.86% (13/14) | 100.00% (0/0) | 92.31% (12/13) |
src/governance/GuildTimelockController.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 25.00% (1/4) |
src/governance/GuildVetoGovernor.sol | 96.49% (55/57) | 96.72% (59/61) | 90.00% (18/20) | 94.44% (17/18) |
src/governance/LendingTermOffboarding.sol | 100.00% (33/33) | 100.00% (34/34) | 92.86% (26/28) | 100.00% (5/5) |
src/governance/LendingTermOnboarding.sol | 100.00% (36/36) | 100.00% (40/40) | 100.00% (22/22) | 100.00% (5/5) |
src/governance/ProfitManager.sol | 100.00% (117/117) | 100.00% (136/136) | 86.96% (40/46) | 100.00% (15/15) |
src/loan/AuctionHouse.sol | 100.00% (38/38) | 100.00% (44/44) | 95.00% (19/20) | 100.00% (5/5) |
src/loan/LendingTerm.sol | 100.00% (210/210) | 98.82% (251/254) | 87.30% (110/126) | 100.00% (24/24) |
src/loan/SimplePSM.sol | 100.00% (25/25) | 100.00% (27/27) | 100.00% (4/4) | 100.00% (7/7) |
src/loan/SurplusGuildMinter.sol | 100.00% (83/83) | 98.99% (98/99) | 85.00% (34/40) | 100.00% (7/7) |
src/rate-limits/RateLimitedMinter.sol | 100.00% (3/3) | 100.00% (3/3) | 100.00% (0/0) | 100.00% (2/2) |
src/tokens/CreditToken.sol | 100.00% (16/16) | 100.00% (16/16) | 100.00% (4/4) | 100.00% (11/11) |
src/tokens/ERC20Gauges.sol | 100.00% (123/123) | 100.00% (133/133) | 92.00% (46/50) | 100.00% (28/28) |
src/tokens/ERC20MultiVotes.sol | 100.00% (91/91) | 100.00% (102/102) | 73.53% (25/34) | 100.00% (27/27) |
src/tokens/ERC20RebaseDistributor.sol | 99.49% (195/196) | 99.62% (263/264) | 98.39% (61/62) | 100.00% (23/23) |
src/tokens/GuildToken.sol | 100.00% (51/51) | 100.00% (53/53) | 94.44% (17/18) | 100.00% (17/17) |
src/utils/RateLimitedV2.sol | 100.00% (37/37) | 100.00% (46/46) | 100.00% (10/10) | 100.00% (8/8) |
test/forge-std/lib/ds-test/src/test.sol | 0.00% (0/219) | 0.00% (0/222) | 0.00% (0/110) | 0.00% (0/56) |
test/forge-std/src/DSTest.sol | 11.87% (26/219) | 13.06% (29/222) | 11.82% (13/110) | 12.50% (7/56) |
test/forge-std/src/StdAssertions.sol | 98.90% (90/91) | 95.15% (98/103) | 90.00% (36/40) | 95.45% (21/22) |
test/forge-std/src/StdCheats.sol | 0.00% (0/188) | 0.00% (0/228) | 0.00% (0/52) | 0.00% (0/38) |
test/forge-std/src/StdJson.sol | 0.00% (0/29) | 0.00% (0/29) | 100.00% (0/0) | 0.00% (0/29) |
test/forge-std/src/StdMath.sol | 0.00% (0/12) | 0.00% (0/15) | 0.00% (0/4) | 0.00% (0/5) |
test/forge-std/src/StdStorage.sol | 0.00% (0/127) | 0.00% (0/153) | 0.00% (0/30) | 0.00% (0/37) |
test/forge-std/src/StdUtils.sol | 0.00% (0/31) | 0.00% (0/40) | 0.00% (0/26) | 0.00% (0/5) |
test/forge-std/src/console.sol | 0.00% (0/381) | 0.00% (0/381) | 100.00% (0/0) | 0.00% (0/380) |
test/forge-std/src/console2.sol | 0.00% (0/381) | 0.00% (0/381) | 100.00% (0/0) | 0.00% (0/380) |
test/forge-std/test/StdAssertions.t.sol | 100.00% (21/21) | 100.00% (21/21) | 100.00% (0/0) | 100.00% (21/21) |
test/forge-std/test/StdCheats.t.sol | 100.00% (5/5) | 100.00% (5/5) | 50.00% (5/10) | 100.00% (3/3) |
test/forge-std/test/StdError.t.sol | 92.31% (12/13) | 93.33% (14/15) | 50.00% (1/2) | 100.00% (10/10) |
test/forge-std/test/StdStorage.t.sol | 60.00% (3/5) | 66.67% (4/6) | 100.00% (0/0) | 50.00% (2/4) |
test/integration/PostProposalCheck.sol | 0.00% (0/6) | 0.00% (0/6) | 100.00% (0/0) | 0.00% (0/1) |
test/integration/PostProposalCheckFixture.sol | 0.00% (0/31) | 0.00% (0/32) | 100.00% (0/0) | 0.00% (0/1) |
test/mock/MockERC20.sol | 66.67% (6/9) | 66.67% (6/9) | 100.00% (0/0) | 57.14% (4/7) |
test/mock/MockERC20Gauges.sol | 87.50% (7/8) | 87.50% (7/8) | 100.00% (0/0) | 87.50% (7/8) |
test/mock/MockERC20MultiVotes.sol | 85.71% (6/7) | 85.71% (6/7) | 100.00% (0/0) | 85.71% (6/7) |
test/mock/MockERC20RebaseDistributor.sol | 85.71% (6/7) | 85.71% (6/7) | 100.00% (0/0) | 85.71% (6/7) |
test/mock/MockRateLimitedV2.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) |
test/proposals/AddressLib.sol | 0.00% (0/46) | 0.00% (0/63) | 0.00% (0/10) | 0.00% (0/4) |
test/proposals/gips/GIP_0.sol | 0.00% (0/209) | 0.00% (0/258) | 100.00% (0/0) | 0.00% (0/5) |
test/proposals/gips/GIP_X.sol | 100.00% (0/0) | 100.00% (0/0) | 100.00% (0/0) | 0.00% (0/5) |
test/proposals/proposalTypes/MultisigProposal.sol | 0.00% (0/7) | 0.00% (0/10) | 0.00% (0/2) | 0.00% (0/3) |
test/proposals/proposalTypes/Proposal.sol | 0.00% (0/1) | 0.00% (0/1) | 100.00% (0/0) | 0.00% (0/1) |
test/proposals/proposalTypes/TimelockProposal.sol | 0.00% (0/38) | 0.00% (0/43) | 0.00% (0/18) | 0.00% (0/3) |
Total | 40.96% (1327/3240) | 42.54% (1538/3615) | 54.78% (493/900) | 23.38% (310/1326) |
Files like Core.sol, CoreRef.sol, LendingTermOnboarding.sol, and several others show 100% coverage across lines, statements, branches, and functions. This indicates that every line of code, every conditional branch, and every function in these files have been executed during testing, which is ideal.
Files such as GuildGovernor.sol, GuildVetoGovernor.sol, and LendingTerm.sol have less than 100% coverage in some areas. This could be a cause for concern, especially in a DeFi protocol, where untested code might lead to vulnerabilities or unexpected behaviors. The test/forge-std/... files have significantly lower coverage. These might be standard test utilities or mock contracts used for testing other components. While lower coverage here might be less critical, it's still worth investigating if it impacts the reliability of the tests.
Security Risks
: In DeFi, untested code could lead to security vulnerabilities, potentially leading to loss of funds or other exploits.
Functional Bugs
: Lack of coverage might mean that certain edge cases or error conditions are not tested, which could result in bugs or unexpected behavior in production.
Integration Issues
: In a protocol with multiple interacting contracts, untested code in one contract might cause issues in another, especially in complex transactions or state changes.
Design contracts like ERC20RebaseDistributor, AuctionHouse, and LendingTerm as modular components. This allows for easier upgrades and maintenance.
Ensure each contract focuses on a specific functionality, reducing complexity and potential for bugs.
Develop comprehensive unit tests for each function in the contracts, covering all possible edge cases.
Perform stress tests simulating extreme market conditions to evaluate the contracts' resilience.
Use formal verification tools to prove the correctness of critical functions, especially those in ERC20RebaseDistributor involving complex mathematical calculations.
Establish and verify state invariants for contracts to ensure they maintain consistent states throughout transactions.
Implement checks against reentrancy attacks, particularly in functions that interact externally in AuctionHouse.
Refactor contract code to minimize gas consumption, crucial for functions likely to be called frequently.
If upgradeability is a requirement, consider using proxy patterns, ensuring a clear separation between data and logic.
Maintain strict version control and changelogs for all contracts to track changes and upgrades over time.
Develop and maintain clear, transparent processes for governance actions, documented in code comments and developer documentation.
Emit detailed events in contracts for better off-chain tracking and to facilitate user interface updates.
Maintain detailed documentation for each contract, describing its purpose, functions, and interaction mechanisms.
function _balance2shares(uint256 balance, uint256 sharePrice) internal pure returns (uint256) { return (balance * START_REBASING_SHARE_PRICE) / sharePrice; } function _shares2balance(uint256 shares, uint256 sharePrice, uint256 deltaBalance, uint256 minBalance) internal pure returns (uint256) { uint256 rebasedBalance = (shares * sharePrice) / START_REBASING_SHARE_PRICE + deltaBalance; if (rebasedBalance < minBalance) { rebasedBalance = minBalance; } return rebasedBalance; }
These functions handle the conversion between balances and shares, which is a core part of the rebasing logic. The risk here is primarily due to potential rounding errors and integer division limitations in Solidity. Incorrect conversions could lead to balance inaccuracies, especially when dealing with large numbers or very small fractional amounts.
function getBidDetail(bytes32 loanId) public view returns (uint256 collateralReceived, uint256 creditAsked) { // ... logic for calculating bid details ... }
The getBidDetail function calculates how much collateral is offered and how much debt is asked for, based on the time elapsed in the auction. The logic is complex and time-dependent, making it prone to miscalculations or manipulation, especially if not properly validated against edge cases or unexpected scenarios (like extreme market conditions).
if (amount < 0) {
The handling of losses (negative values) is complex and involves multiple state changes (e.g., adjusting surplus buffers, updating credit multipliers). If not managed accurately, it could lead to financial discrepancies in the system, such as incorrect loss accounting or multiplier adjustments.
// update the CREDIT multiplier uint256 creditTotalSupply = CreditToken(_credit).totalSupply(); uint256 newCreditMultiplier = (creditMultiplier * (creditTotalSupply - loss)) / creditTotalSupply; creditMultiplier = newCreditMultiplier; emit CreditMultiplierUpdate( block.timestamp, newCreditMultiplier );
Decrease in CREDIT Value
: The decrease in the creditMultiplier effectively lowers the value of CREDIT tokens throughout the system. This means that every CREDIT token now represents a smaller share of the system's value.
Increased Debt for Borrowers
: This decrease means borrowers now owe more CREDIT tokens to cover the same nominal value of debt.
29 hours
#0 - c4-pre-sort
2024-01-05T17:56:20Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-sponsor
2024-01-11T11:03:08Z
eswak (sponsor) acknowledged
#2 - c4-judge
2024-01-31T04:48:07Z
Trumpero marked the issue as grade-a
#3 - c4-judge
2024-01-31T04:48:11Z
Trumpero marked the issue as selected for report