Cyfrin_Hub::fund_investor allows ETH contributions after share cap is reached, resulting in loss of funds
Description
-
The expected behavior is that, when the number of issued shares issued_shares reaches the public cap public_shares_cap, the contract reverts any new investment attempt or, alternatively, refunds the excess ETH sent.
-
However, the current assert does not revert in this case, since it allows an investment exactly equal to the cap or greater after capping new_shares. This allows investors to send ETH that the contract accepts without issuing the corresponding shares, and without returning the excess. This causes a direct economic loss to the investor and an internally inflated balance.
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
@> assert (
self.issued_shares <= self.public_shares_cap
), "Share cap reached!!!"
assert (self.company_balance > self.holding_debt), "Company is insolvent!!!"
(...)
available: uint256 = self.public_shares_cap - self.issued_shares
@> if new_shares > available:
new_shares = available
self.shares[msg.sender] += new_shares
Risk
Likelihood: High
-
This will occur when investors continue investing in late rounds, with the cap nearly exhausted.
-
It will also occur when there are fewer shares available than the amount the sent value can buy, a common situation at the end of a round.
Impact: High
-
Investors lose ETH for shares they do not receive.
-
The company_balance is artificially inflated, breaking economic logic and simulations.
Proof of Concept
-
Dan sends 10 ETH when only 1 share remains → he receives 1 share but no refund; the contract keeps the full 10 ETH.
-
The cap becomes exhausted.
-
Elise sends 10 ETH with 0 shares available → it does not revert, she receives no shares and also loses the 10 ETH; the contract balance increases as well.
def test_fund_investor_no_refund_and_no_revert_when_capped(industry_contract):
public_shares_cap = industry_contract.public_shares_cap()
issued_before = industry_contract.issued_shares()
available_shares = public_shares_cap - issued_before
assert available_shares == 1 # Only 1 share left
company_balance_before = industry_contract.get_balance()
dan_eth_before = boa.env.get_balance(dan)
elise_eth_before = boa.env.get_balance(elise)
assert dan_eth_before == to_wei(10, "ether")
assert elise_eth_before == to_wei(10, "ether")
with boa.env.prank(dan):
industry_contract.fund_cyfrin(1, value=to_wei(10, "ether"))
assert industry_contract.shares(dan) == 1
assert boa.env.get_balance(dan) == 0
assert industry_contract.get_balance() == company_balance_before + dan_eth_before
issued_after_dan = industry_contract.issued_shares()
assert issued_after_dan == public_shares_cap
with boa.env.prank(elise):
industry_contract.fund_cyfrin(1, value=to_wei(10, "ether"))
assert industry_contract.shares(elise) == 0
assert boa.env.get_balance(elise) == 0
assert industry_contract.get_balance() == company_balance_before + dan_eth_before + elise_eth_before
assert industry_contract.issued_shares() == issued_after_dan == public_shares_cap
tests/unit/test_company.py::test_fund_investor_no_refund_and_no_revert_when_capped PASSED [100%]
Recommended Mitigation
The fix ensures that, once the share cap is reached, the contract reverts new investments and refunds any excess ETH sent. Only the amount actually used to issue shares is credited to the balance, and the ETHRefunded event provides transparency about refunds made.
____ events _____
+ event ETHRefunded:
+ investor: address
+ amount: uint256
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
- assert (
- self.issued_shares <= self.public_shares_cap
- ), "Share cap reached!!!"
assert (self.company_balance > self.holding_debt), "Company is insolvent!!!"
+ assert (
+ self.issued_shares < self.public_shares_cap
+ ), "Share cap reached!!!"
(...)
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
+ if self.share_received_time[msg.sender] == 0:
+ self.share_received_time[msg.sender] = block.timestamp
- self.company_balance += msg.value
+ value_used: uint256 = new_shares * share_price
+ self.company_balance += value_used
- if self.share_received_time[msg.sender] == 0:
- self.share_received_time[msg.sender] = block.timestam
+ change: uint256 = msg.value - value_used
+ if change > 0:
+ raw_call(msg.sender, b"", value=change, revert_on_failure=True)
+ log ETHRefunded(investor=msg.sender, amount=change)
log SharesIssued(investor=msg.sender, amount=new_shares)