Hesty Disclosed Report

Users' funds will be permanently locked if `cancelProperty` is executed after the threshold is reached

Company
Created date
Mar 12 2025

Target

https://github.com/la-bomba-studio/hesty-contract/tree/29596447b9a06d4ad53360d4a15f349ebf9fa0d8

Vulnerability Details

Description:
The cancelProperty function in TokenFactory contract can prevent users from claiming refunds via recoverFundsInvested if the investment threshold is reached before cancellation.

When cancelProperty is called, it modifies the contract state by setting raiseDeadline to 0, approved to false, and deadProperty to true:

    function cancelProperty(uint256 id) external onlyAdmin{

@>        property[id].raiseDeadline = 0; // Important to allow investors to recover funds
@>        property[id].approved = false;  // Prevent more investments
@>        deadProperty[id] = true;

        emit CancelProperty(id);
    }

These state changes ensure that:

  • Users can no longer purchase tokens for that property.
  • The admin cannot restart the raise for the same property, as the approveProperty function checks for deadProperty.
  • The extendRaiseForProperty function cannot be executed since raiseDeadline is set to 0.

This results in a situation where, once a property is canceled, it cannot be reactivated.

Now, as the property is canceled, users/investors should be able to retrieve their investments via recoverFundsInvested function. However, if the investment threshold is reached before cancellation, the following statement prevents users from reclaiming their funds:

@>        require(p.raised * p.price < p.threshold, "Threshold reached, cannot recover funds");

This means users are permanently unable to withdraw their investments, leading to funds being indefinitely locked in the contract with no mechanism for retrieval.

This issue can occur in the following scenarios:

    • A property manager decides to cancel the raise, and the admin calls the cancelProperty function.
    • However, before the transaction is executed, additional investments push the total raised amount above the threshold.
    • Once cancelProperty is executed, the contract state changes, preventing users from claiming refunds via recoverFundsInvested.
    • As a result, all invested funds remain permanently locked in the contract with no way to recover them.
    • A malicious actor intentionally buys tokens via the buyToken function, ensuring that the threshold is reached before the admin cancels the property.
    • This would trigger the same issue, where cancelProperty executes after the threshold is met, making it impossible for investors to claim refunds.
    • Since there are no functions to retrieve the locked funds, they remain permanently stuck in the contract.

Impact:

  • Affected party: All investors in the canceled property raise.
  • Loss: Investors permanently lose their funds as they are unable to claim refunds.
  • Additional Loss: The admin cannot complete the raise or reapprove the property to unlock funds, and as there are no functions to retrieve the funds, all invested funds are permanently locked in the contract.

Mitigation:
Modify the recoverFundsInvested function to include a deadProperty check, ensuring that if a property is canceled, investors can claim refunds regardless of whether the threshold has been reached.

Validation steps

Attack Scenario:

  1. A property raise is in progress, and users are investing funds.
  2. The property manager decides to cancel the raise, and the admin calls cancelProperty.
  3. Before the transaction executes, additional investments push the total raised amount above the threshold.
  4. Once cancelProperty is executed, the contract state changes.
  5. Investors attempt to claim refunds via recoverFundsInvested, but the function no longer allows withdrawals due to the threshold being met.
  6. As there are no mechanisms to reverse this state and no functions to retrieve the locked funds, all invested funds are permanently locked in the contract.

Add this test code in TokenFactory.test.js file and run the test:

  describe("Users cannot claim refunds", function () {
    beforeEach(async function () {
      //Not yet initialized so therefore address(0)
      expect(await tokenFactory.referralSystemCtr()).to.equal("0x0000000000000000000000000000000000000000");

      await tokenFactory.initialize(referral.address, issuance.address)

      await hestyAccessControlCtr.connect(addr2).approveUserKYC(propertyManager.address);

      await tokenFactory.addWhitelistedToken(token.address);

      await tokenFactory.connect(propertyManager).createProperty(1000000, 1000, 1, 10, token.address, token.address, "token", "TKN", hestyAccessControlCtr.address)

      expect(await tokenFactory.propertyCounter()).to.equal(1);

      await tokenFactory.approveProperty(0, 2937487238472834);

      // Approve owner kyc to allow him to buy property token
      await hestyAccessControlCtr.connect(addr2).approveUserKYC(owner.address);

      await token.approve(tokenFactory.address, 20);

      await token.mint(owner.address, 15);

      await tokenFactory.buyTokens(owner.address, 0, 2, addr3.address);
    })

    it("FundsNotClaimable", async function () {
      // Property reaches threshold with this
      await tokenFactory.buyTokens(owner.address, 0, 10, addr3.address);

      // Property is canceled
      await tokenFactory.cancelProperty(0);

      await ethers.provider.send("evm_mine", [2937487238472844]);

      // User cannot get refund
      await expect(token.recoverFundsInvested(owner.address, 0)).to.be.revertedWith("Threshold reached, cannot recover funds");

    });

  })

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$457
Visibilitypartially
VulnerabilityBlockchain
Participants (5)
company admin
author
company admin
manager