Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

Investors can bypass share recive time check via invest a tiny

Root + Impact

Description

  • The contract enforces a 30-day lockup for investor withdrawals; withdrawing before the lockup should apply a 10% penalty to the total payout.

  • The lockup timestamp share_received_time[msg.sender] is only set once (on first share acquisition) and never updated on subsequent purchases. An investor can set this timestamp with an earlier buy, wait out the lockup, then make a large purchase and immediately withdraw without penalty, since the timestamp is not refreshed.// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • This occurs whenever an investor performs any initial purchase to set the timestamp, waits at least LOCKUP_PERIOD , then adds more shares.

  • The contract lacks per-lot tracking and does not refresh the lock timestamp on additional buys, so all later buys inherit the old timestamp.

Impact:

  • Investors avoid the 10% early-withdrawal penalty on large later purchases, undermining the lockup mechanism’s intent.

  • Financial loss and unfair advantage: large positions can be withdrawn immediately, draining company funds faster than designed.

Proof of Concept

  • Make a small initial buy to set share_received_time .

  • Wait longer than LOCKUP_PERIOD .

  • Make a large second buy; the timestamp doesn’t refresh.

  • Withdraw immediately; no 10% penalty applies to the new shares (subject to MAX_PAYOUT_PER_SHARE and solvency).

import boa
from eth_utils import to_wei
from script.deploy import deploy_industry
LOCKUP_PERIOD = 30 * 86400
def test_lockup_bypass(PATRICK):
# Deploy a fresh contract to avoid shared-session state from other tests
industry_contract = deploy_industry()
OWNER = industry_contract.OWNER_ADDRESS()
# Arrange: ensure solvency and reasonable share distribution
boa.env.set_balance(OWNER, to_wei(100, "ether"))
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=to_wei(1, "ether")) # owner funding
# Initial buy to set share_received_time (make issued_shares large so cap doesn't trigger)
boa.env.set_balance(PATRICK, to_wei(1000, "ether"))
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(1, value=to_wei(10, "ether")) # many shares at INITIAL_SHARE_PRICE
# Wait out the lockup
boa.env.time_travel(LOCKUP_PERIOD + 1)
# Record the old timestamp
old_ts = industry_contract.share_received_time(PATRICK)
# Large buy after lockup elapses (timestamp will NOT refresh due to the bug)
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(1, value=to_wei(1, "ether"))
# The bug: timestamp should not have changed
new_ts = industry_contract.share_received_time(PATRICK)
assert new_ts == old_ts, "Lockup timestamp should not refresh on subsequent buys (bug demonstration)"
# Compute expected payout without penalty (as the bug allows)
# Use direct mapping getter to avoid caller context issues
shares_owned = industry_contract.shares(PATRICK)
net_worth = max(industry_contract.get_balance() - industry_contract.holding_debt(), 0)
issued = industry_contract.issued_shares()
assert issued > 0
share_price_withdraw = net_worth // issued
expected_full_payout = shares_owned * share_price_withdraw
# Cap consideration (0.002 ETH per share)
MAX_PAYOUT_PER_SHARE = 2 * 10**15
expected_full_capped = min(expected_full_payout, shares_owned * MAX_PAYOUT_PER_SHARE)
bal_before = industry_contract.get_balance()
with boa.env.prank(PATRICK):
industry_contract.withdraw_shares()
bal_after = industry_contract.get_balance()
actual_payout = bal_before - bal_after
# With bypass, there should be NO 10% penalty applied, and cap may bind.
expected_penalized = expected_full_payout - (expected_full_payout * 10 // 100)
expected_penalized_capped = min(expected_penalized, shares_owned * MAX_PAYOUT_PER_SHARE)
# Assert bypass: actual equals full-capped, and strictly greater than penalized-capped
print("shares_owned", shares_owned)
print("issued_shares", issued)
print("net_worth", net_worth)
print("share_price", share_price_withdraw)
print("expected_full_payout", expected_full_payout)
print("expected_full_capped", expected_full_capped)
print("expected_penalized_capped", expected_penalized_capped)
print("actual_payout", actual_payout)
assert actual_payout == expected_full_capped
assert actual_payout > expected_penalized_capped

Recommended Mitigation

  • Refresh the lock timestamp on any successful share acquisition (i.e., when new_shares > 0 ). This is the simplest and safest fix given the contract’s “whole-position” withdrawal design.

  • Optional defense-in-depth: If you need per-lot fairness (old lots unlocked, new lots locked), track purchase lots with timestamps and add a partial withdraw function; this is a broader change

@payable
@internal
def fund_investor():
"""
@notice Allows public users to invest ETH in exchange for shares.
@dev Share amount is calculated based on current net worth per share.
If no shares have been issued, uses INITIAL_SHARE_PRICE.
@dev Investor receives shares proportional to contribution.
Excess shares beyond cap are trimmed.
@dev Emits SharesIssued event.
"""
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!!!"
# 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 // max(self.issued_shares, 1)
if self.issued_shares > 0
else INITIAL_SHARE_PRICE
)
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
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
+ # Reset lockup whenever new shares are acquired to prevent bypass
+ if new_shares > 0:
+ self.share_received_time[msg.sender] = block.timestamp
log SharesIssued(investor=msg.sender, amount=new_shares)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
about 1 month ago
0xshaedyw Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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

Give us feedback!