https://github.com/la-bomba-studio/hesty-contract/tree/29596447b9a06d4ad53360d4a15f349ebf9fa0d8
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);
1e6 ether (automatically scaled by 10^18) total supply and a price of 100;1e6 / 100 property tokens;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);
}
}