Hesty Disclosed Report

Incorrect amounts of property tokens are distributed

Company
Created date
Mar 03 2025

Target

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

Vulnerability Details

There is an amount scale mismatch between PropertyToken and TokenFactory contracts.

When creating property tokens, their initial supply is multiplied by 10^18 before minting to the manager contract (initialSupply_ * 1 ether):

    constructor(
        address tokenManagerContract_,
        uint256 initialSupply_,
        string memory  name_,
        string memory symbol_,
        address rewardAsset_,
        address ctrHestyControl_,
        address owner
    ) ERC20(name_, symbol_) AccessControlDefaultAdminRules(
    3 days,
     owner // Explicit initial `DEFAULT_ADMIN_ROLE` holder
    ){

        // Supplies higher than TEN_POWER_FIFTEEN ether will result in precision lost
        require(initialSupply_ < TEN_POWER_FIFTEEN, "Precision lost");

        // Property Token Supply Issuance
        _mint(address(tokenManagerContract_), initialSupply_ * 1 ether);

        rewardAsset     = IERC20(rewardAsset_);
        ctrHestyControl = IHestyAccessControl(ctrHestyControl_);

    }

However, the manager contract that hold tokens, that is TokenFactory does not know this scale, it stores the unscaled amount:

address newAsset = IIssuance(ctrHestyIssuance).createPropertyToken(
            amount,
            address(revenueToken),
            name,
            symbol,
            admin,
            ctrHestyControl.owner() );

        property[propertyCounter++] = PropertyInfo( tokenPrice,
                                                    amount,
                                                    threshold,
                                                    0,
                                                    0,
                                                    false,
                                                    false,
                                                    false,
                                                    msg.sender,
                                                    msg.sender,
                                                    IERC20(paymentToken),
                                                    newAsset,
                                                    IERC20(revenueToken));

This means that TokenFactory thinks and distributes X amount while it actually has X^18 tokens. After the sale completes, users can claim only pennies and the majority (>99.9%) of property tokens will forever reside in the token factory contract. buyTokens credits user amount which should not be above p.amountToSell:

...
require(p.raised + amount <= p.amountToSell, "Too much raised");
...
rightForTokens[onBehalfOf][id] += amount;

So dividends are lost because once distributed, users will practically get nothing because of the incorrect their balance and total supply ratio:

dividendPerToken += amount * MULTIPLIER / super.totalSupply();
uint256 amount             = ( (dividendPerToken - xDividendPerToken[account]) * balanceOf(account) / MULTIPLIER);

Validation steps

  1. Property is created with 1e6 ether (automatically scaled by 10^18) total supply and a price of 100;
  2. User can purchase up to 1e6 / 100 property tokens;
  3. Sale completes and user gets all tokens;
  4. The majority of supply stays in the factory (1e6 ether - 1e6 / 100).

I wrote a Foundry test case to showcase this vulnerability:

// SPDX-License-Identifier: UNLICENCED
pragma solidity ^0.8.19;

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

contract MyERC20 is ERC20 {
    constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {}
}

contract MyTest is Test {
    HestyAccessControl public hestyAccessControl;
    TokenFactory public tokenFactory;
    MyERC20 public eurc;
    MyERC20 public usdc;
    HestyAssetIssuance public issuanceContract;
    ReferralSystem public referralSystem;
    HestyRouter public hestyRouter;

    uint256 internal testPropertyId;

    bytes32 public constant KYC_MANAGER = keccak256("KYC_MANAGER");

    function setUp() public {
        hestyAccessControl = new HestyAccessControl();

        tokenFactory = new TokenFactory(
            300,
            100,
            0x168090283962c5129A2CBc91E099369297f32437,
            1,
            address(hestyAccessControl)
        );

        eurc = new MyERC20(
            "Euro Circle",
            "EURC"
        );

        usdc = new MyERC20(
            "Dollar",
            "USDC"
        );

        referralSystem = new ReferralSystem(
            address(eurc),
            address(hestyAccessControl),
            address(tokenFactory)
        );

        issuanceContract = new HestyAssetIssuance(
            address(tokenFactory)
        );

        hestyRouter = new HestyRouter(
            address(tokenFactory),
            address(hestyAccessControl)
        );

        tokenFactory.initialize(
            address(referralSystem),
            address(issuanceContract)
        );

        // Create test property
        hestyAccessControl.grantRole(KYC_MANAGER, address(this));
        hestyAccessControl.approveKYCOnly(address(this));
        tokenFactory.addWhitelistedToken(address(eurc));
        tokenFactory.addWhitelistedToken(address(usdc));

        testPropertyId = tokenFactory.createProperty(
            1e6,
            1000,
            100,
            0,
            address(usdc),
            address(eurc),
            "Test Property",
            "TP",
            address(hestyAccessControl)
        );

        tokenFactory.approveProperty(testPropertyId, block.timestamp + 1 weeks);
    }
		
  function test_IncorrectDistribution()
    public
    virtual
    {
        address userA = address(1);
        deal(address(usdc), userA, 1 ether);
        hestyAccessControl.approveKYCOnly(userA);

        (
        /*uint256 price*/,
            uint256 amountToSell,
        /*uint256 threshold*/,
        /*uint256 raised*/,
        /*uint256 raiseDeadline*/,
        /*bool isCompleted*/,
        /*bool approved*/,
        /*bool extended*/,
        /*address owner*/,
        /*address ownerExchAddr*/,
        /*address paymentToken*/,
            address asset,
        /*address revenueToken*/
        ) = tokenFactory.property(0);

        ERC20 propertyToken = ERC20(asset);
        assertEq(propertyToken.balanceOf(address(tokenFactory)), 1e6 ether);
        assertEq(amountToSell, 1e6);

        vm.startPrank(userA);
        usdc.approve(address(tokenFactory), usdc.balanceOf(userA));
        vm.expectRevert(); // require(p.raised + amount <= p.amountToSell, "Too much raised");
        tokenFactory.buyTokens(userA, testPropertyId, 1 ether, address(0));
        tokenFactory.buyTokens(userA, testPropertyId, 1e6 / 100, address(0));
        vm.stopPrank();

        tokenFactory.completeRaise(testPropertyId);

        tokenFactory.getInvestmentTokens(userA, testPropertyId);
        assertEq(propertyToken.balanceOf(address(userA)), 1e6 / 100);
        assertEq(propertyToken.balanceOf(address(tokenFactory)), 1e6 ether - 1e6 / 100);
    }
  }

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$700
Visibilitypartially
VulnerabilityOther
Participants (3)
company admin
author
company admin