https://github.com/la-bomba-studio/hesty-contract/tree/29596447b9a06d4ad53360d4a15f349ebf9fa0d8
The TokenFactory contract exposes a flawed state transition handling between its cancelProperty and completeRaise functions, creating a condition where user investments become permanently locked if a property is canceled after meeting its threshold but before a successful raise completion. Because the threshold check in recoverFundsInvested disallows refunds once the threshold is met, and because cancellation sets approved = false, the contract ends up in a dead state. No one can complete the raise or recover funds, effectively trapping user assets in the contract.
The problem arises from the interplay between three functions in the TokenFactory:
1- cancelProperty:
Sets raiseDeadline = 0, approved = false, and deadProperty[id] = true.
Code snippet:
function cancelProperty(uint256 id) external onlyAdmin {
property[id].raiseDeadline = 0;
property[id].approved = false;
deadProperty[id] = true;
emit CancelProperty(id);
}
2- completeRaise:
Requires property[id].approved == true and checks that p.raised * p.price ≥ p.threshold. Once canceled, property[id].approved = false, blocking completion.
Code snippet:
function completeRaise(uint256 id) external onlyAdmin {
PropertyInfo storage p = property[id];
require(p.approved && !p.isCompleted, "Canceled or Already Completed");
require(p.raised * p.price >= property[id].threshold, "Threshold not met");
property[id].isCompleted = true;
// Transfer logic ...
}
3- recoverFundsInvested:
Checks (!p.isCompleted) and (p.raiseDeadline < block.timestamp) and (p.raised * p.price < p.threshold). If a property’s threshold is already met, this path disallows refunds.
Code snippet:
function recoverFundsInvested(address user, uint256 id)
external nonReentrant whenNotAllPaused {
PropertyInfo storage p = property[id];
require(p.raiseDeadline < block.timestamp && !p.isCompleted, "Time not valid");
require(p.raised * p.price < p.threshold, "Threshold reached, cannot recover funds");
// ... Recovery logic ...
}
Sequence of Failure:
completeRaise called.cancelProperty, which zeroes out the raiseDeadline and sets approved = false.completeRaise cannot be called to finalize or distribute funds.recoverFundsInvested remains blocked because p.raised * p.price ≥ p.threshold.This flaw critically locks user capital by banning both recovery (due to meeting the threshold) and proper completion (due to canceled approval). Once triggered, neither the contract owner nor the users have any avenue to withdraw or redistribute those funds, resulting in a permanent fund lockup.
WORKING POC ATTACHED BELOW.
Change the file type from ".txt" to ".js" and run it inside the project with npx hardhat test test/Vulnerabilities/TokenFactoryLockFunds.test.js
Here are its logs::
=== Starting Vulnerability Demonstration ===
1. Property Created and Approved:
- Property ID: 0
- Total Tokens: 100
- Token Price: 10.0 ETH
- Threshold: 500.0 ETH
- Initial Raised: 0.0 ETH
2. Investment Details:
- Tokens to Buy: 60
- Price per Token: 10.0 ETH
- Base Investment: 600.0 ETH
- Platform Fee (3%): 18.0 ETH
- Listing Fee (10%): 60.0 ETH
- Investor Initial Balance: 1000.0 ETH
3. Post-Investment State:
- Total Raised: 600.0 ETH
- Threshold Required: 500.0 ETH
- Threshold Met: Yes
4. Property Canceled:
- Property Approved: false
- Raise Deadline: 0
5. Attempting to Complete Raise (Expected to Fail)
✓ Raise completion failed as expected
6. Attempting to Recover Funds (Expected to Fail)
✓ Fund recovery failed as expected
7. Final Balances:
- Investor's Initial Balance: 1000.0 ETH
- Investor's Current Balance: 382.0 ETH
- Amount Locked in Contract: 618.0 ETH
- Total Investor Loss: 618.0 ETH
=== Vulnerability Successfully Demonstrated ===
Funds are permanently locked in the contract with no way to recover them.
✔ Should demonstrate funds lock when canceling after threshold is met (56ms)
1 passing (193ms)