Root + Impact
Description
Normal behavior: fund_investor() accepts ETH and mints shares to investors up to public_shares_cap. The function must prevent issuance beyond the cap and should either refund or reject excess payments when the cap is reached.
Specific issue: The function uses a non-strict assertion assert self.issued_shares <= self.public_shares_cap which permits execution to continue when issued_shares == public_shares_cap. In that state the code can compute new_shares = 0 (or truncate later) and still credit msg.value to company_balance, resulting in an investor sending ETH and receiving zero shares (donation). This creates an economic loss for investors and a vector that can be exploited by reorderers/MEV actors to force donations.
@payable
@internal
def fund_investor():
...
assert (
self.issued_shares <= self.public_shares_cap
), "Share cap reached!!!" @> root check uses <= instead of strict <
# 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
if self.issued_shares > 0
else INITIAL_SHARE_PRICE
)
new_shares: uint256 = msg.value
# Cap shares if exceeding visible limit
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available @> truncation without preventing full msg.value accounting
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value @> full msg.value credited even when new_shares == 0
...
Risk
Likelihood:
A large number of investors submit near-simultaneous funding transactions during open funding windows, producing states where issued_shares == public_shares_cap at the time a later transaction executes.
MEV/front-running bots monitor pending funding transactions and reorder or insert transactions to consume remaining cap right before victim transactions, creating many victim transactions that execute with an exhausted cap.
Impact:
Investors send ETH and receive zero or fewer shares than paid for, causing irreversible financial loss (forced donation).
MEV actors or early buyers profit by causing or benefiting from truncated allocations while the contract retains the full ETH amounts.
Proof of Concept
Explanation
Owner funds contract to ensure sufficient balance for share price calculation.
Attacker purchases all remaining shares (fills cap) before victim’s transaction.
Victim submits transaction with full ETH amount, but due to assert issued_shares <= public_shares_cap being non-strict, the transaction does not revert.
Truncation logic sets new_shares = 0, yet the full msg.value is added to company_balance.
Effect: victim loses ETH with zero shares; MEV/front-runner benefits indirectly.
This directly demonstrates the donation attack vector stemming from the non-strict share cap assertion.
import boa
from eth_utils import to_wei
SET_OWNER_BALANCE = to_wei(1000, "ether")
FUND_VALUE = to_wei(20, "ether")
SHARE_CAP = 1_000_000 # match contract initial public_shares_cap
INITIAL_SHARE_PRICE = 1 * 10**15 # contract constant
def test_ZeroShareDonation_PoC(industry_contract, OWNER, ATTACKER, VICTIM):
# Step 1: Owner funds the contract
boa.env.set_balance(OWNER, SET_OWNER_BALANCE)
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=FUND_VALUE)
# Step 2: Fill the share cap with a front-runner (ATTACKER)
# ATTACKER buys exactly the remaining shares
remaining_shares = SHARE_CAP # no shares issued yet
cost_for_attacker = remaining_shares * INITIAL_SHARE_PRICE
boa.env.set_balance(ATTACKER, cost_for_attacker)
with boa.env.prank(ATTACKER):
industry_contract.fund_cyfrin(1, value=cost_for_attacker)
# Step 3: Victim sends ETH to buy shares, expecting allocation
boa.env.set_balance(VICTIM, FUND_VALUE)
with boa.env.prank(VICTIM):
industry_contract.fund_cyfrin(1, value=FUND_VALUE)
# Step 4: Verify Victim received zero shares
victim_shares = industry_contract.get_my_shares(caller=VICTIM)
assert victim_shares == 0, f"Victim should have 0 shares, got {victim_shares}"
# Step 5: Verify contract still received full ETH
contract_balance = industry_contract.get_balance()
expected_balance = FUND_VALUE + cost_for_attacker + FUND_VALUE # owner + attacker + victim
assert contract_balance == expected_balance, (
f"Contract balance mismatch: expected {expected_balance}, got {contract_balance}"
)
Recommended Mitigation
Require a strict precondition on the cap, reject zero-share purchases, and refund any remainder when truncation occurs. Additionally, introduce slippage protection in the public API so callers can require a minimum allocation.
- assert (
- self.issued_shares <= self.public_shares_cap
- ), "Share cap reached!!!"
+ assert (
+ self.issued_shares < self.public_shares_cap
+ ), "Share cap reached"
- new_shares: uint256 = msg.value
-
- # 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
+ new_shares: uint256 = msg.value
+ assert new_shares > 0, "Insufficient ETH for a share"
+
+ # Cap shares if exceeding visible limit
+ available: uint256 = self.public_shares_cap - self.issued_shares
+ if new_shares > available:
+ new_shares = available
+
+ # compute cost of issued shares and refund remainder
+ cost_for_shares: uint256 = new_shares * share_price
+ if msg.value > cost_for_shares:
+ refund: uint256 = msg.value - cost_for_shares
+ # require refund to succeed to avoid accepting excess funds unintentionally
+ raw_call(msg.sender, b"", value=refund, revert_on_failure=True)
+
+ # increase company balance only by charged amount
+ self.company_balance += cost_for_shares
+
+ # issue shares and update supply
+ self.shares[msg.sender] += new_shares
+ self.issued_shares += new_shares
+
+ # optional: enforce caller specified minimum acceptable allocation (slippage protection)
+ # function signature must accept min_expected_shares and assert new_shares >= min_expected_shares