Root + Impact
Description
-
The Cyfrin_Hub::fund_investorfunction lets investors buy shares in the company through calling the fund_cyfrinfunction.
-
When the issued_shares equals the public_shares_cap there are no more shares for investors to buy. If an investor calls fund_cyfrinwhen this limit is reached the contract accepts the ETH even though the investor does not get any shares in return.
@payable
@internal
def fund_investor():
"""
@notice Allows public users to invest ETH in exchange for shares.
@dev Share amount is calculated based on current net worth per share.
If no shares have been issued, uses INITIAL_SHARE_PRICE.
@dev Investor receives shares proportional to contribution.
Excess shares beyond cap are trimmed.
@dev Emits SharesIssued event.
"""
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!!!"
net_worth: uint256 = 0
if self.company_balance > self.holding_debt:
net_worth = self.company_balance - self.holding_debt
share_price: uint256 = (
net_worth // max(self.issued_shares, 1)
if self.issued_shares > 0
else INITIAL_SHARE_PRICE
)
new_shares: uint256 = msg.value // share_price
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value
if self.share_received_time[msg.sender] == 0:
self.share_received_time[msg.sender] = block.timestamp
log SharesIssued(investor=msg.sender, amount=new_shares)
Risk
Likelihood:
-
The condition triggers naturaly under normal operation.
-
There is no special setup or exploit required.
-
Its deterministic, reproducable, and likely in real world deployment.
Impact:
-
The investor loses ETH permanently because:
-
This is direct loss of user funds and a violation of expected behavior.
-
It also breaks the company accounting (company balance grows without matching issued shares)
Proof of Concept
Flow:
Pascal buys 1 million shares which is the public_shars_cap
Dacian funds company with 15 ETH
Company accepts the 15 ETH but gives no shares to Dacian
Dacian tries to withdraw funds but because he has no shares the transaction reverts
SET_OWNER_BALANCE = to_wei(1000, "ether")
FUND_VALUE_PASCAL = to_wei(10_000, "ether")
FUND_VALUE_DACIAN = to_wei(15, "ether")
def test_fund_investor_when_issued_shares_is_equal_to_public_shares_cap(industry_contract, PASCAL, OWNER, DACIAN):
boa.env.set_balance(OWNER, SET_OWNER_BALANCE)
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0,value=to_wei(50, "ether"))
with boa.env.prank(PASCAL):
industry_contract.fund_cyfrin(1,value=FUND_VALUE_PASCAL)
print(f"Pascal's shares after funding: {industry_contract.get_my_shares()}")
with boa.env.prank(DACIAN):
industry_contract.fund_cyfrin(1,value=FUND_VALUE_DACIAN)
print(f"Dacian's shares after funding: {industry_contract.get_my_shares()}")
assert industry_contract.get_my_shares() == 0, "Dacian should have received shares after funding"
boa.env.time_travel(86400 * 30)
with boa.reverts("Not an investor!!!"):
industry_contract.withdraw_shares()
Recommended Mitigation
Set the assert to less than instead of less than or equal:
@payable
@internal
def fund_investor():
"""
@notice Allows public users to invest ETH in exchange for shares.
@dev Share amount is calculated based on current net worth per share.
If no shares have been issued, uses INITIAL_SHARE_PRICE.
@dev Investor receives shares proportional to contribution.
Excess shares beyond cap are trimmed.
@dev Emits SharesIssued event.
"""
assert msg.value > 0, "Must send ETH!!!"
assert (
- self.issued_shares <= self.public_shares_cap
+ self.issued_shares < self.public_shares_cap
), "Share cap reached!!!"
# code