Company Simulator

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

Deposit Slips caused investor funds accepted without minting shares

Author Revealed upon completion

Root + Impact

Description

fund_investor accepts ETH, computes new_shares, and only then caps the issuance. When integer division or trimming drives new_shares to zero, the call still completes: it credits the vault balance, emits SharesIssued, and even records a lockup timestamp, yet the caller receives no shares.

@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
...
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
@> 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

Risk

Likelihood: Fractional deposits or hard-cap trims are normal events. Any user sending less than one share-worth of ETH immediately triggers the bug. Attack complexity is low.

Impact:

  • Depositors can lose the full amount sent because zero shares are minted.

  • share_received_time is still set, letting attackers skip the early withdrawal penalty later.

  • Audit logs mislead operators because SharesIssued fires for zero-amount share sales.

Proof of Concept

  1. Call fund_cyfrin(1) with 0.0005 ETH.

  2. Observe shares[attacker] == 0 while company_balance increased by 0.0005 ETH.

  3. Later deposit a larger amount, mint shares, and immediately exit via withdraw_shares; the stored timestamp predates the lockup period so no penalty applies.

Forge-style reproduction outline:

def test_zero_allocation_refund(vy_environment, hub, attacker):
value = 5 * 10**14 # 0.0005 ETH
hub.fund_cyfrin(value=value, action=1, sender=attacker)
assert hub.shares(attacker) == 0
assert hub.company_balance() == value

Recommended Mitigation

  1. Revert if new_shares == 0 before mutating state.

  2. When trimming to available, refund msg.value - new_shares * share_price and only retain funds backing minted shares.

  3. Update timestamps only when new_shares > 0 and clear them inside withdraw_shares.

Proposed patch (Vyper-like pseudocode):

new_shares: uint256 = msg.value // share_price
+assert new_shares > 0, "Contribution too small"
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
- new_shares = available
+ minted_value: uint256 = available * share_price
+ refund: uint256 = msg.value - minted_value
+ if refund > 0:
+ raw_call(msg.sender, b"", value=refund, revert_on_failure=True)
+ new_shares = available
+ self.company_balance += minted_value
+else:
+ self.company_balance += msg.value
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
+if new_shares > 0:
+ self.share_received_time[msg.sender] = block.timestamp

Support

FAQs

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