Company Simulator

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

Investor Overpayment at Share Cap

Author Revealed upon completion

Root + Impact

Description

  • Cyfrin_Hub.fund_investor caps new_shares to the remaining supply but still increments company_balance by the full msg.value, so investors overpay whenever they request more shares than the cap allows.

```vyper
@external
@payable
def fund_investor():
...
new_shares: uint256 = msg.value // share_price
available: uint256 = self.public_shares_cap - self.issued_shares
@> if new_shares > available:
@> new_shares = available # Shares capped to remaining supply
@> self.shares[msg.sender] += new_shares # Only capped shares issued
@> self.issued_shares += new_shares
@> self.company_balance += msg.value # But full payment accepted, no refund
...
```

Risk

Likelihood: High

  • Near-cap states are expected at launch; single mis-sized tx triggers the bug and UI race conditions make it easy to overshoot.


Impact: High

  • Excess payment is never refunded or compensated with shares, producing direct investor loss and inflating treasury balance.

Proof of Concept

  • Overview: Python test test_poc_002_investor_overpayment.py fills the cap to 999,000 shares, attempts to buy 2,000 more, and proves only 1,000 shares are issued while full payment for 2,000 is kept.

  • Step-by-step:

    1. Setup: Owner seeds 1 ETH; investor A mints 999,000 shares (leaving 1,000 remaining slots).

    2. Attack Vector: Investor B sends payment for 2,000 shares in a single call.

    3. Execution Flow: Contract mints only 1,000 shares (capped to remaining supply) but accepts full payment for 2,000.

    4. Result: Investor pays for 2,000 shares but receives only 1,000; excess payment (~1 ETH) trapped in company_balance with no refund.

"""
POC-002: Investor Overpayment at Share Cap
Severity: HIGH | Likelihood: High | Impact: High
Proves that `Cyfrin_Hub.fund_investor()` trims shares to the remaining
cap but keeps the full payment, causing investors to overpay with no
refund when the cap is nearly exhausted.
"""
# ============================================================================
# EXECUTION CONTEXT
# ============================================================================
# This PoC uses pytest fixtures from conftest.py that:
# - Deploy original protocol contracts: industry_contract (Cyfrin_Hub.vy)
# - Provide funded test accounts: OWNER (deployer), PATRICK (first investor),
# DACIAN (second investor attempting overpayment)
#
# Framework: titanoboa (Python testing framework for Vyper smart contracts)
# Dependencies: pytest, titanoboa, eth_utils
# ============================================================================
import boa
from eth_utils import to_wei
INITIAL_SHARE_PRICE_WEI = 10**15 # 0.001 ETH
TARGET_NEAR_CAP = 999_000
DESIRED_SHARES = 2_000 # Attempt to purchase double the remaining supply
def _current_share_price(contract) -> int:
"""
Mirror `fund_investor` share price logic so we can reason about
payments before invoking the vulnerable function.
"""
issued = contract.issued_shares()
holding_debt = contract.holding_debt()
balance = contract.company_balance()
if issued == 0:
return INITIAL_SHARE_PRICE_WEI
net_worth = balance - holding_debt if balance > holding_debt else 0
return net_worth // issued
def test_poc_002_investor_overpayment_at_share_cap(
industry_contract,
OWNER,
PATRICK,
DACIAN,
):
"""
Scenario:
- Owner seeds small positive balance (required by fund_investor)
- First investor buys 999,000 shares (cap = 1,000,000)
- Second investor attempts to buy 2,000 shares in one tx
- Contract mints only the remaining 1,000 shares but keeps the full payment
"""
# Fund key actors with enough ETH for large deposits
boa.env.set_balance(OWNER, to_wei(1500, "ether"))
boa.env.set_balance(PATRICK, to_wei(1500, "ether"))
boa.env.set_balance(DACIAN, to_wei(1500, "ether"))
# 1) Owner seeds minimal capital so `company_balance > holding_debt`
owner_seed = to_wei(1, "ether")
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=owner_seed)
# 2) Pre-fill public supply to 999,000 / 1,000,000 shares
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(
1, value=INITIAL_SHARE_PRICE_WEI * TARGET_NEAR_CAP
)
issued_before = industry_contract.issued_shares()
cap = industry_contract.public_shares_cap()
assert issued_before == TARGET_NEAR_CAP
assert cap - issued_before == 1_000
share_price_before = _current_share_price(industry_contract)
company_balance_before = industry_contract.company_balance()
attacker_balance_before = boa.env.get_balance(DACIAN)
payment = share_price_before * DESIRED_SHARES
# 3) Attacker tries to buy 2,000 shares (double the remaining slots)
with boa.env.prank(DACIAN):
industry_contract.fund_cyfrin(1, value=payment)
issued_after = industry_contract.issued_shares()
attacker_balance_after = boa.env.get_balance(DACIAN)
company_balance_after = industry_contract.company_balance()
attacker_shares = industry_contract.shares(DACIAN)
paid = attacker_balance_before - attacker_balance_after
minted = attacker_shares
available = cap - issued_before
overpayment = paid - (share_price_before * minted)
assert issued_after == cap # Share cap fully reached
assert minted == available # Only remaining 1,000 shares minted
assert paid == payment # Full payment kept by contract
assert company_balance_after - company_balance_before == payment
assert overpayment > 0 # At least half of the payment bought nothing
print("\n<<< POC-002 >>>")
print(f"share_price_before={share_price_before} wei/share")
print(f"remaining_shares={available}")
print(f"desired_shares={DESIRED_SHARES}")
print(f"investor_paid={paid} wei")
print(f"shares_minted={minted}")
print(f"overpayment={overpayment} wei")
balance_delta = company_balance_after - company_balance_before
print(
"state_snapshot="
f"{{'issued_before': {issued_before}, "
f"'issued_after': {issued_after}, "
f"'company_balance_delta': {balance_delta}}}"
)
print("[fail] fund_investor refunds=0; excess locked as company balance")

Recommended Mitigation

  • Recompute payment as share_price * new_shares after clipping and refund msg.value - (share_price * new_shares) in the same transaction.

  • Alternatively, revert when msg.value overshoots to force investors to size their txs conservatively.

  • Emit explicit events when refunds occur so analytics can reconcile the treasury.

- self.shares[msg.sender] += new_shares
- self.issued_shares += new_shares
- self.company_balance += msg.value
+ cost: uint256 = new_shares * share_price
+ refund: uint256 = msg.value - cost
+
+ self.shares[msg.sender] += new_shares
+ self.issued_shares += new_shares
+ self.company_balance += cost
+
+ if refund > 0:
+ raw_call(msg.sender, b"", value=refund, revert_on_failure=True)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
2 days ago
0xshaedyw Lead Judge about 2 hours 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.