Hesty Disclosed Report

Investment Fees stuck in the contract when property is cancelled

Company
Created date
Mar 13 2025

Target

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

Vulnerability Details

The buyTokens function allows users to purchase property tokens and records the amount invested in the userInvested mapping. When a property is canceled using the cancelProperty function, users should be able to withdraw their entire investment. However, the recorded investment amount does not account for the investment fee, leading to incorrect refund calculations. Additionally, there is no function to allow system administrators to withdraw accumulated investment fees from canceled properties, resulting in those funds being permanently locked in the contract.

When a user buys tokens, the contract calculates and deducts an investment fee:

uint256 fee = boughtTokensPrice * platformFeeBasisPoints / BASIS_POINTS;
uint256 total = boughtTokensPrice + fee;

However, only boughtTokensPrice (excluding the fee) is recorded in the userInvested mapping:

userInvested[msg.sender][id] += boughtTokensPrice;

Upon property cancellation, users can only withdraw the recorded amount, meaning the investment fee is not refunded.

Validation steps

In the hesky project, run forge init --force. Copy the code below into the test file in the test folder and run it.


// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import  "forge-std/Test.sol";
import  { PropertyToken } from  "../contracts/TokenFactory.sol";
import {ERC20PresetMinterPauser } from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";
import {Token} from "./ERC20.sol";
import {HestyAccessControl} from "../contracts/HestyAccessControl.sol";
import {TokenFactory} from "../contracts/TokenFactory.sol";
import {ReferralSystem} from "../contracts/Referral/ReferralSystem.sol";
import {HestyAssetIssuance } from "../contracts/HestyAssetIssuance.sol";

contract PropertyTest is Test {
    PropertyToken public propToken;
    ERC20PresetMinterPauser public token;
   HestyAccessControl public accessControl;
   TokenFactory public tokenFact;
   ReferralSystem public refSys;
   HestyAssetIssuance public issuance;
   Token public payment;
   Token public revenue;

    address user1 = makeAddr("user1");
    address user2 = makeAddr("user2");
    address user3 = makeAddr("user3");
    address user4 = makeAddr("user4");
    address user5 = makeAddr("user5");


    function setUp() public {

        vm.startPrank(user1);

        token = new ERC20PresetMinterPauser("name","symbol");
        accessControl = new HestyAccessControl();
        propToken = new PropertyToken(user1, 10, "Token", "TKN", address(token), address( accessControl), user1);
        tokenFact = new TokenFactory(300, 100, user1, 1, address(accessControl));
        refSys = new   ReferralSystem (address(token),address( accessControl), address(tokenFact) );
        issuance = new HestyAssetIssuance(address(tokenFact));
        tokenFact.initialize(address(refSys),address(issuance));

        accessControl.grantRole(keccak256("KYC_MANAGER"), user1);
        
        deal(address( accessControl), 100 ether);

        accessControl.approveUserKYC(user2);
        accessControl.approveUserKYC(user3);

        payment = new Token("payment","payment");
        revenue = new Token("rev","rev");

        payment.mint(user3, 100 ether);

        tokenFact.addWhitelistedToken(address(payment));
        tokenFact.addWhitelistedToken(address(revenue));

        vm.stopPrank();
    }



    function test_InvestmentFeeStuck() public {
       // User2 creates a property
       vm.startPrank(user2);

       tokenFact.createProperty(
        100,
        2000,
        1e18,
        100e18,
        address(payment),
        address(revenue),
        "prop",
        "prop",
        user2
        );

        // User1 (Admin) approves the property
        vm.startPrank(user1);
        tokenFact.approveProperty(0, block.timestamp + 10 minutes);

        // User3 (buyer) buys token
        vm.startPrank(user3);
        payment.approve(address(tokenFact), 100 ether);
        tokenFact.buyTokens(
        user3,
        0,
        10,
        user4
        );

        // User1 (Admin) cancels property
        vm.startPrank(user1);
        tokenFact.cancelProperty(0);

        // User3 tried to recover funds invested
        vm.startPrank(user3);
        tokenFact.recoverFundsInvested(user3, 0);

        // User3 does not get full refund, the invested fee was not refunded
        assert(payment.balanceOf(user3) != 100 ether);

        // Investment Fee is stuck in the contract as admin cannot withdraw it as well
    }
}

Attachments

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