Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: medium
Likelihood: medium

Silent “donations”: funds accepted but zero shares issued

Author Revealed upon completion

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:

    1. 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.

    2. 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!!!"
# compute net_worth & share_price
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) # when issued_shares > 0
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

# tests/test_donations_zero_shares.py
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)
# Make company solvent and set cap == 0 (issued_shares already equals cap) by not allowing any shares.
with boa.env.prank(owner):
company.fund_cyfrin(0, value=10**19) # seed
# public_shares_cap defaults to 1_000_000; we force "full cap" by setting issued_shares == cap.
# For test simplicity, set cap to 0 so available=0 without altering totals.
company.public_shares_cap = 0
# Now issued_shares (0) <= cap (0) passes assert; available = 0
# Any user investment should revert, but it won't: it will accept ETH and mint 0 shares.
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) # 1 ETH
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)
# Make the company extremely "valuable" per share to force share_price > msg.value
with boa.env.prank(owner):
# Seed huge balance and a tiny outstanding share count (>0 so pricing uses net_worth / issued_shares)
company.fund_cyfrin(0, value=10**22) # 10,000 ETH to company balance
# Artificially set a very small issued_shares to inflate share_price (test-only direct write)
company.issued_shares = 1 # WARNING: assumes test env allows direct storage set
# Now share_price ~= net_worth / 1 -> ~10,000 ETH >> 1 ETH
# new_shares = 1 ETH // 10,000 ETH -> 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) # 1 ETH
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)

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.