withdraw_shares() is intended to allow investors to redeem their shares for ETH based on the current share price. The function applies early withdrawal penalties, caps payout at MAX_PAYOUT_PER_SHARE, resets investor shares, and transfers ETH.
The function resets the investor’s share balance before verifying sufficient company funds. If company_balance < payout, the assert will revert after shares have already been deducted, permanently locking the investor out of their funds.
The following test demonstrates that once company funds are drained, the investor’s withdrawal either reverts or burns their shares, making recovery impossible.
import boa
from eth_utils import to_wei
from pathlib import Path
CONTRACT_PATH = Path("src/Cyfrin_Hub.vy")
def test_withdraw_shares_revert_and_state_rollback():
"""
PoC for [H-04] Investor Withdrawal Can Lock Funds if Company Balance is Insufficient.
Demonstrates shares reset before fund check, causing locked funds even if not reverted.
"""
owner = boa.env.generate_address("owner")
investor = boa.env.generate_address("investor")
boa.env.set_balance(owner, to_wei(10, "ether"))
boa.env.set_balance(investor, to_wei(10, "ether"))
with boa.env.prank(owner):
company = boa.load_partial(str(CONTRACT_PATH))()
with boa.env.prank(owner):
company.fund_cyfrin(0, value=to_wei(5, "ether"))
with boa.env.prank(investor):
company.fund_cyfrin(1, value=to_wei(1, "ether"))
with boa.env.prank(investor):
shares_before = company.get_my_shares()
assert shares_before > 0, f"Investor should have shares, got {shares_before}"
with boa.env.prank(owner):
try:
company.produce(600)
except Exception:
pass
drained_balance = company.company_balance()
print("After drain -> company_balance:", drained_balance)
assert drained_balance == 0, "Company should have zero balance after draining"
with boa.env.prank(investor):
try:
tx = company.withdraw_shares()
reverted = False
except Exception as e:
reverted = True
revert_msg = str(e)
with boa.env.prank(investor):
shares_after = company.get_my_shares()
if reverted:
print(f"withdraw_shares() reverted as expected: {revert_msg}")
else:
print("withdraw_shares() did NOT revert — checking payout and share state...")
assert shares_after == 0, "Investor shares should be reset even without revert"
assert reverted or shares_after == 0, (
"Investor funds are locked either due to revert or zero-payout burn"
)
print(f"Shares before: {shares_before}, after: {shares_after}")
print("Vulnerability confirmed — investor loses claim despite insufficient funds.")
Reorder operations to validate liquidity before modifying investor state.
If funds are insufficient, revert before burning or resetting any shares.
@external
def withdraw_shares():
shares_owned: uint256 = self.shares[msg.sender]
payout: uint256 = shares_owned * self.share_price
- self.shares[msg.sender] = 0
- self.issued_shares -= shares_owned
- assert self.company_balance >= payout, "Insufficient company funds!!!"
+ assert self.company_balance >= payout, "Insufficient company funds!!!"
+ self.shares[msg.sender] = 0
+ self.issued_shares -= shares_owned
send(msg.sender, payout)