Company Simulator

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

``Cyfrin_Hub::fund_investor`` allows ETH contributions after share cap is reached, resulting in loss of funds

Cyfrin_Hub::fund_investor allows ETH contributions after share cap is reached, resulting in loss of funds

Description

  • The expected behavior is that, when the number of issued shares issued_shares reaches the public cap public_shares_cap, the contract reverts any new investment attempt or, alternatively, refunds the excess ETH sent.

  • However, the current assert does not revert in this case, since it allows an investment exactly equal to the cap or greater after capping new_shares. This allows investors to send ETH that the contract accepts without issuing the corresponding shares, and without returning the excess. This causes a direct economic loss to the investor and an internally inflated balance.

@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!!!"
(...)
available: uint256 = self.public_shares_cap - self.issued_shares
@> if new_shares > available:
new_shares = available
self.shares[msg.sender] += new_shares

Risk

Likelihood: High

  • This will occur when investors continue investing in late rounds, with the cap nearly exhausted.

  • It will also occur when there are fewer shares available than the amount the sent value can buy, a common situation at the end of a round.

Impact: High

  • Investors lose ETH for shares they do not receive.

  • The company_balance is artificially inflated, breaking economic logic and simulations.

Proof of Concept

  • Dan sends 10 ETH when only 1 share remains → he receives 1 share but no refund; the contract keeps the full 10 ETH.

  • The cap becomes exhausted.

  • Elise sends 10 ETH with 0 shares available → it does not revert, she receives no shares and also loses the 10 ETH; the contract balance increases as well.

def test_fund_investor_no_refund_and_no_revert_when_capped(industry_contract):
public_shares_cap = industry_contract.public_shares_cap()
issued_before = industry_contract.issued_shares()
available_shares = public_shares_cap - issued_before
assert available_shares == 1 # Only 1 share left
company_balance_before = industry_contract.get_balance()
dan_eth_before = boa.env.get_balance(dan)
elise_eth_before = boa.env.get_balance(elise)
assert dan_eth_before == to_wei(10, "ether")
assert elise_eth_before == to_wei(10, "ether")
// Dan sends 10 ETH; he can only receive 1 share
with boa.env.prank(dan):
industry_contract.fund_cyfrin(1, value=to_wei(10, "ether"))
// Receives exactly 1 share...
assert industry_contract.shares(dan) == 1
// ...but NO refund: balance goes to 0
assert boa.env.get_balance(dan) == 0
// The contract balance increases by the full 10 ETH (no refund deducted)
assert industry_contract.get_balance() == company_balance_before + dan_eth_before
// Cap is exhausted
issued_after_dan = industry_contract.issued_shares()
assert issued_after_dan == public_shares_cap
// Elise sends 10 ETH with 0 shares available
with boa.env.prank(elise):
// Important: It must NOT revert according to the current behavior (bug)
industry_contract.fund_cyfrin(1, value=to_wei(10, "ether"))
// Elise receives no shares...
assert industry_contract.shares(elise) == 0
// ...and also receives no refund: balance goes to 0
assert boa.env.get_balance(elise) == 0
// The contract balance increases by Elise's 10 ETH as well
assert industry_contract.get_balance() == company_balance_before + dan_eth_before + elise_eth_before
// Total issued does not change after Elise (no shares were available)
assert industry_contract.issued_shares() == issued_after_dan == public_shares_cap
tests/unit/test_company.py::test_fund_investor_no_refund_and_no_revert_when_capped PASSED [100%]

Recommended Mitigation

The fix ensures that, once the share cap is reached, the contract reverts new investments and refunds any excess ETH sent. Only the amount actually used to issue shares is credited to the balance, and the ETHRefunded event provides transparency about refunds made.

____ events _____
+ event ETHRefunded:
+ investor: address
+ amount: uint256
@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!!!"
+ assert (
+ self.issued_shares < self.public_shares_cap
+ ), "Share cap reached!!!"
(...)
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
+ if self.share_received_time[msg.sender] == 0:
+ self.share_received_time[msg.sender] = block.timestamp
- self.company_balance += msg.value
+ value_used: uint256 = new_shares * share_price
+ self.company_balance += value_used
- if self.share_received_time[msg.sender] == 0:
- self.share_received_time[msg.sender] = block.timestam
+ change: uint256 = msg.value - value_used
+ if change > 0:
+ raw_call(msg.sender, b"", value=change, revert_on_failure=True)
+ log ETHRefunded(investor=msg.sender, amount=change)
log SharesIssued(investor=msg.sender, amount=new_shares)
Updates

Lead Judging Commences

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