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: 32/127
Findings: 1
Award: $430.75
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Silvermist
Also found by: ElCid, Topmark, carrotsmuggler, rbserver
430.7502 USDC - $430.75
partialRepay()
allows a user to pay off part of his open loan in lendingTerm
. If the term in question established an interestRate
this will be charged to the borrower in the function's execution.
The issue with invoking partialRepay
arises as it will always revert when attempting to do so within a term featuring both 0
interest rate and 0
opening fees.
This happens due to the following check:
require(principalRepaid != 0 && interestRepaid != 0, "LendingTerm: repay too small");
To arrive at a state where interestRepaid = 0
, both params.interestRate
and params.openingFee
must be set to 0
at term's creation. The incompatibility with such behaviour arises as Ethereum Credit Gild
clearly intends to support terms with these specific parameters. The following lines of code are from LendingTermOnboarding.createTerm()
, where all Lending Term parameters are checked before cloning:
require( params.interestRate < 1e18, // interest rate [0, 100[% APR "LendingTermOnboarding: invalid interestRate" ); require( params.openingFee <= 0.1e18, // open fee expected [0, 10]% "LendingTermOnboarding: invalid openingFee" );
and so, if such term were to be cloned, users would not be able to partialRepay()
their loans.
It is important to mention the capability of creating terms that "impose periodic partial repayments". The term creator can set a frequency for these payments through maxDelayBetweenPartialRepay
and specify the minimum accepted percentage through minPartialRepayPercent
. If the borrower misses a mandatory partial repayment, his collateralAmount
will be auctioned.
All terms with params.interestRate = params.openingFee = 0
and that enforce regular partial repayments can put the borrower's collateral at risk. As he cannot fulfill his periodic payments, as soon as maxDelayBetweenPartialRepay
is surpassed his collateral will be at stake of being called and auctioned. The only way the borrower has to save his collateral from auction is to call repay()
before this time limit is reached.
// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.13; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Test} from "@forge-std/Test.sol"; import {Core} from "@src/core/Core.sol"; import {CoreRoles} from "@src/core/CoreRoles.sol"; import {MockERC20} from "@test/mock/MockERC20.sol"; import {SimplePSM} from "@src/loan/SimplePSM.sol"; import {GuildToken} from "@src/tokens/GuildToken.sol"; import {CreditToken} from "@src/tokens/CreditToken.sol"; import {LendingTerm} from "@src/loan/LendingTerm.sol"; import {AuctionHouse} from "@src/loan/AuctionHouse.sol"; import {ProfitManager} from "@src/governance/ProfitManager.sol"; import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol"; contract ZeroInterestBug is Test { address private governor = address(1); address private guardian = address(2); Core private core; ProfitManager private profitManager; CreditToken credit; GuildToken guild; MockERC20 collateral; SimplePSM private psm; RateLimitedMinter rlcm; AuctionHouse auctionHouse; LendingTerm term; // LendingTerm params uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 2000e18; uint256 constant _INTEREST_RATE = 0; uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 63115200; uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0.2e18; uint256 constant _HARDCAP = 20_000_000e18; uint256 public issuance = 0; function setUp() public { vm.warp(1679067867); vm.roll(16848497); core = new Core(); profitManager = new ProfitManager(address(core)); collateral = new MockERC20(); credit = new CreditToken(address(core), "name", "symbol"); guild = new GuildToken( address(core), address(profitManager) ); rlcm = new RateLimitedMinter( address(core) /*_core*/, address(credit) /*_token*/, CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/, type(uint256).max /*_maxRateLimitPerSecond*/, type(uint128).max /*_rateLimitPerSecond*/, type(uint128).max /*_bufferCap*/ ); auctionHouse = new AuctionHouse(address(core), 650, 1800); term = LendingTerm(Clones.clone(address(new LendingTerm()))); term.initialize( address(core), LendingTerm.LendingTermReferences({ profitManager: address(profitManager), guildToken: address(guild), auctionHouse: address(auctionHouse), creditMinter: address(rlcm), creditToken: address(credit) }), LendingTerm.LendingTermParams({ collateralToken: address(collateral), maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN, interestRate: _INTEREST_RATE, maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY, minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT, openingFee: 0, hardCap: _HARDCAP }) ); psm = new SimplePSM( address(core), address(profitManager), address(credit), address(collateral) ); profitManager.initializeReferences(address(credit), address(guild), address(psm)); // roles core.grantRole(CoreRoles.GOVERNOR, governor); core.grantRole(CoreRoles.GUARDIAN, guardian); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); core.grantRole(CoreRoles.GUILD_MINTER, address(this)); core.grantRole(CoreRoles.GAUGE_ADD, address(this)); core.grantRole(CoreRoles.GAUGE_REMOVE, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(rlcm)); core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term)); core.renounceRole(CoreRoles.GOVERNOR, address(this)); // add gauge and vote for it guild.setMaxGauges(10); guild.addGauge(1, address(term)); guild.mint(address(this), _HARDCAP * 2); guild.incrementGauge(address(term), _HARDCAP); // labels vm.label(address(core), "core"); vm.label(address(profitManager), "profitManager"); vm.label(address(collateral), "collateral"); vm.label(address(credit), "credit"); vm.label(address(guild), "guild"); vm.label(address(rlcm), "rlcm"); vm.label(address(auctionHouse), "auctionHouse"); vm.label(address(term), "term"); vm.label(address(this), "test"); } function testPartialRepayBug() public { //@elcid // prepare & borrow address user = vm.addr(1); uint256 borrowAmount = 20_000e18; uint256 collateralAmount = 15e18; collateral.mint(user, collateralAmount); vm.startPrank(user); collateral.approve(address(term), collateralAmount); bytes32 loanId = term.borrow(borrowAmount, collateralAmount); assertEq(term.getLoan(loanId).collateralAmount, collateralAmount); // partialRepay vm.warp(block.timestamp + term.YEAR()); vm.roll(block.number + 1); vm.expectRevert(); term.partialRepay(loanId, 11_000e18); } }
Other
#0 - c4-pre-sort
2024-01-04T10:18:42Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-04T10:18:51Z
0xSorryNotSorry marked the issue as duplicate of #782
#2 - c4-judge
2024-01-29T02:01:29Z
Trumpero marked the issue as satisfactory