Company Simulator

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

In Cyfrin_Hub contract investor funds are accepted even after share cap is reached

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!!!"
# Calculate shares based on contribution
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
# Cap shares if exceeding visible limit
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:

    • the function accepts ETH

    • no shares are minted

    • no refund is issued

    • the funds cannot be withdrawn

  • 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):
# Owner funds the contract first
boa.env.set_balance(OWNER, SET_OWNER_BALANCE)
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0,value=to_wei(50, "ether"))
# Pascal buys all 1_000_000 available shares
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()}")
# Dacian tries to buy shares but there should be none left
# But the contract still accepts the funds
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"
# Pass time to allow withdrawals
boa.env.time_travel(86400 * 30) # Travel 30 days into the future
# Dacian attempts to withdraw shares but should fail because he has no shares
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
Updates

Lead Judging Commences

0xshaedyw Lead Judge 8 days 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.