Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: medium
Valid

Silent “donations”: funds accepted but zero shares issued

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)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
about 2 months ago
0xshaedyw Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Medium – Excess Contribution Not Refunded

Investor ETH above share cap is accepted without refund or shares, breaking fairness.

Support

FAQs

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

Give us feedback!