Description
-
Public users invest ETH via fund_cyfrin(1) which routes to fund_investor(). The function calculates a share_price from the company’s net worth and mints new_shares = msg.value // share_price to msg.sender. If the public share cap is reached, investment should be rejected.
-
There are two code paths where the contract accepts ETH but issues 0 shares:
Share-price truncation: when share_price becomes too large relative to msg.value (e.g., due to integer division with a tiny issued_shares and large net_worth), new_shares = msg.value // share_price can be 0. The function still adds msg.value to company_balance and emits SharesIssued, effectively taking a donation.
Cap edge case: the guard is assert self.issued_shares <= self.public_shares_cap. When equal, available = 0. The code then caps new_shares to available (i.e., 0) but does not revert and still increases company_balance, again accepting funds without giving shares.
// Root cause in the codebase with @> marks to highlight the relevant section
// File: Cyfrin_Hub.vy (CompanyGame)
@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!!!"
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 // @> can be 0 due to truncation
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available // @> may become 0 when cap is full
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value // @> takes funds even if new_shares == 0
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: Medium
-
Happens whenever share_price rounds so high that msg.value // share_price == 0 (e.g., large net_worth with very few issued_shares, or a tiny msg.value).
-
Happens whenever issued_shares == public_shares_cap; the current guard still allows entry and silently issues 0 shares while taking funds.
Impact: Medium
-
Funds taken without consideration — Users pay but receive no shares, contrary to user expectations and economic design.
-
User trust and legal risk — This behavior can be construed as donation but is not disclosed; likely to cause disputes and support issues.
Proof of Concept
import boa
COMPANY_PATH = "src/Cyfrin_Hub.vy"
def _deploy(owner):
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
return company
def test_cap_edge_case_accepts_funds_but_mints_zero(donations_zero_shares=True):
owner = boa.env.generate_address()
user = boa.env.generate_address()
boa.env.set_balance(owner, 10**21)
boa.env.set_balance(user, 10**21)
company = _deploy(owner)
with boa.env.prank(owner):
company.fund_cyfrin(0, value=10**19)
company.public_shares_cap = 0
with boa.env.prank(user):
before_bal_company = company.get_balance()
before_user_shares = company.get_my_shares()
company.fund_cyfrin(1, value=10**18)
after_bal_company = company.get_balance()
after_user_shares = company.get_my_shares()
assert after_user_shares == before_user_shares == 0, "No shares were issued (0)"
assert after_bal_company == before_bal_company + 10**18, "Company took user's ETH despite issuing 0 shares"
print("[✓] Cap edge case: funds accepted, 0 shares issued (silent donation)")
def test_share_price_truncation_mints_zero_shares_but_accepts_eth():
owner = boa.env.generate_address()
user = boa.env.generate_address()
boa.env.set_balance(owner, 10**21)
boa.env.set_balance(user, 10**21)
company = _deploy(owner)
with boa.env.prank(owner):
company.fund_cyfrin(0, value=10**22)
company.issued_shares = 1
with boa.env.prank(user):
before_bal_company = company.get_balance()
before_user_shares = company.get_my_shares()
company.fund_cyfrin(1, value=10**18)
after_bal_company = company.get_balance()
after_user_shares = company.get_my_shares()
assert after_user_shares == before_user_shares == 0, "No shares were issued due to truncation"
assert after_bal_company == before_bal_company + 10**18, "Company took ETH despite issuing 0 shares"
print("[✓] Truncation case: funds accepted, 0 shares issued (silent donation)")
Recommended Mitigation
Make “zero‑share issuance” impossible and strictly enforce the cap condition.
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
- assert (self.issued_shares <= self.public_shares_cap), "Share cap reached!!!"
+ # If cap is full, do not accept funds
+ assert (self.issued_shares < self.public_shares_cap), "Share cap reached!!!"
assert (self.company_balance > self.holding_debt), "Company is insolvent!!!"
# ... compute share_price as before ...
new_shares: uint256 = msg.value // share_price
+ # Prevent silent donations: user must receive at least one share
+ assert new_shares > 0, "Contribution too small for one share at current price"
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
- new_shares = available
+ # Revert instead of silently trimming to 0 (or partial mint)
+ raise "Not enough shares available at current cap"
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
+ # Refresh lockup timestamp on any positive issuance (also fixes lockup-bypass bug)
+ self.share_received_time[msg.sender] = block.timestamp
log SharesIssued(investor=msg.sender, amount=new_shares)