Company Simulator

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

Zero Shares Issued Despite ETH Payment in `fund_investor`

Author Revealed upon completion

Zero Shares Issued Despite ETH Payment in fund_investor

H-2 - User Funds Lost Due to Rounding and Zero-Share Issuance

Description

The fund_investor() function is designed to allow public users to invest ETH in exchange for proportional shares based on the current share price.

  • Normal Behavior: The number of shares granted is calculated using integer division: new_shares = msg.value // share_price. The protocol expects to either issue a non-zero amount of shares or to revert.

  • Issue: When an investor's payment (msg.value) is less than the calculated share_price, Vyper's integer division rounds the result down to zero (new_shares = 0). The function checks if new_shares > available but does not check if new_shares > 0. Consequently, the code executes the following:

    1. new_shares is set to 0.

    2. The full msg.value amount is added to self.company_balance.

    3. The investor receives 0 shares (self.shares[msg.sender] += 0).

This breaks the fundamental economic invariant: A non-zero payment must yield non-zero equity or be fully refunded. An investor can lose up to the full price of one share (minus a tiny epsilon) in a single transaction, unjustly inflating the company's internal balance at the expense of the investor.

// src/Cyfrin_Hub.vy:363
)
@> 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 # secondary risk (can also result in 0 shares)
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
@> self.company_balance += msg.value // ETH is accepted even if new_shares is 0

Risk

Likelihood: High

  • This occurs whenever an investor's contribution is slightly less than the current share price.

Impact: High

  • Economic Loss to Investor: An investor can lose the entire value of their investment (up to the full price of a single share) without receiving any equity.

  • Unjust Enrichment: The company's company_balance increases without corresponding issuance of shares, breaking the fundamental economic fairness model of the protocol.

Proof of Concept

Note on Test Environment Setup (Addressing Vyper's Internal Visibility):

The core logic for calculating share_price resides in the internal function get_share_price() within Cyfrin_Hub.vy. To accurately calculate the required attack amount () and provide irrefutable evidence for the PoC, we must read this internal value.

To enable this without modifying the core logic of the vulnerability, a temporary external helper function, get_current_share_price(), was added to the public interface of Cyfrin_Hub.vy solely for testing purposes. This allows the test environment to access the precise share_price and correctly execute the scenario where Investment Amount < Share Price.

Place this function in the empty space on line 395.

# src/Cyfrin_Hub.vy:395
@view
@external
def get_current_share_price() -> uint256:
"""
@notice External wrapper for internal share price calculation.
@dev Added for testing and external transparency.
@return Price per share in wei.
"""
return self.get_share_price()

This test confirms that an investment of nearly the full share price results in 0 shares being issued, and the substantial payment is fully and unjustly retained by the company.

# File: tests/unit/test_vulnerabilities_poc.py
import boa
import pytest
from eth_utils import to_wei
# NOTE: The PoC requires the external helper function `get_current_share_price()`
# to be added to Cyfrin_Hub.vy to read the internal share price.
def test_AP02_zero_share_price_loss_of_large_investment(industry_contract, OWNER, PATRICK):
"""
Test AP-02 (V5): Proves loss of a significant investment amount (Share Price - Epsilon)
due to rounding the issued shares down to zero.
"""
print("Testing AP-02 (V5): Proving Loss of Substantial ETH")
# --- Arrange ---
# 1. Fund the company (10 ETH) and produce to establish base state
initial_fund = to_wei(10, "ether")
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=initial_fund)
industry_contract.produce(50)
# 2. PATRICK makes a first investment (1 ETH) to establish issued_shares > 0
first_investment = to_wei(1, "ether")
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(1, value=first_investment)
# 3. Fast forward time to incur debt and establish the dynamic share price.
boa.env.time_travel(3600 * 48)
# 4. Read the current share price using the helper function
share_price_expected = industry_contract.get_current_share_price()
# --- Attack ---
# The attack amount must be: (Share Price) - (Small Epsilon)
# This guarantees that the integer division (msg.value // share_price) = 0.
epsilon = 1000 # Small amount in Wei
investment_amount_lost = share_price_expected - epsilon
# Record state before attack
company_balance_before_attack = industry_contract.get_balance()
patrick_shares_before_attack = industry_contract.get_my_shares(caller=PATRICK)
# Act: PATRICK invests the substantial, yet slightly insufficient, amount
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(1, value=investment_amount_lost)
# --- Assert ---
patrick_shares_after_attack = industry_contract.get_my_shares(caller=PATRICK)
company_balance_after_attack = industry_contract.get_balance()
# 1. No new shares were issued (new_shares = 0)
assert patrick_shares_after_attack == patrick_shares_before_attack, "No new shares should have been issued"
# 2. Company unjustly kept the ETH payment (the entire investment is lost)
assert company_balance_after_attack == company_balance_before_attack + investment_amount_lost, "Company unjustly kept the ETH payment"
print(f"Share Price: {share_price_expected} Wei")
print(f"Investment Amount Lost (Full Amount): {investment_amount_lost} Wei")
print(f"Shares Issued: {patrick_shares_after_attack - patrick_shares_before_attack}")
print("CONFIRMED: Investor lost a substantial ETH amount due to rounding to zero shares.")

Verified Test Output:

Testing AP-02 (V5): Proving Loss of Substantial ETH
Share Price: 10500000000000000 Wei
Investment Amount Lost (Full Amount): 10499999999999000 Wei
Shares Issued: 0
CONFIRMED: Investor lost a substantial ETH amount due to rounding to zero shares.
PASSED

Recommended Mitigation

The function should revert if the calculated new_shares is zero. This guarantees that for every payment, the investor either receives equity or has their funds returned automatically. Furthermore, implement an explicit check for a full cap to reject payments when no shares are available.

// src/Cyfrin_Hub.vy
@internal
@payable
def fund_investor():
# ...
# Calculate shares based on contribution
# ...
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 == 0:
+ # Revert if the contribution is too small to receive at least 1 full share
+ raise "Contribution too small for a single share!!!"
+
# Cap shares if exceeding visible limit
available: uint256 = self.public_shares_cap - self.issued_shares
+ if available == 0:
+ # Explicitly revert if no shares are available, rejecting payment
+ raise "Share cap reached!"
+
if new_shares > available:
new_shares = available
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value
# ...

Support

FAQs

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