Company Simulator

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

Lockup logic bypass after full withdrawal

Author Revealed upon completion

Description

  • Investors who receive shares have a lockup period (LOCKUP_PERIOD = 30 days). If they withdraw before the lockup ends, a 10% penalty (EARLY_WITHDRAWAL_PENALTY) is applied. The timestamp used to evaluate the lockup is recorded in share_received_time[msg.sender] when the investor first acquires shares.

  • The timestamp share_received_time[msg.sender] is set only when it is zero (i.e., on the first-ever share acquisition) and is never reset or refreshed on subsequent share purchases. After an investor fully withdraws (their shares set to 0), a later reinvestment does not update share_received_time, so the account appears to have held shares since the original (older) date. This allows an investor to bypass the lockup penalty on new shares by withdrawing, then reinvesting and immediately withdrawing again.

// Root cause in the codebase with @> marks to highlight the relevant section
// File: Cyfrin_Hub.vy (CompanyGame)
@payable
@internal
def fund_investor():
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!!!"
# ... compute share_price, new_shares, available ...
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value
# @> Timestamp is only set once, and never updated after further purchases
if self.share_received_time[msg.sender] == 0:
self.share_received_time[msg.sender] = block.timestamp
log SharesIssued(investor=msg.sender, amount=new_shares)
@external
def withdraw_shares():
shares_owned: uint256 = self.shares[msg.sender]
assert shares_owned > 0, "Not an investor!!!"
share_price: uint256 = self.get_share_price()
payout: uint256 = shares_owned * share_price
# @> Lockup calculation depends on a stale timestamp that persists across full withdrawals
time_held: uint256 = block.timestamp - self.share_received_time[msg.sender]
if time_held < LOCKUP_PERIOD:
penalty: uint256 = payout * EARLY_WITHDRAWAL_PENALTY // 100
payout -= penalty
# ... cap payout, zero shares, update issued_shares, pay out ...

Risk

Likelihood: High

  • Occurs whenever an investor fully withdraws their shares and later reinvests; the original share_received_time remains unchanged, so subsequent withdrawals can be performed immediately without penalty.

  • Occurs whenever active traders use fast in–out cycles (e.g., to exploit pricing movements) and wish to avoid the intended lockup economics.

Impact: High

  • Penalty avoidance / unfair advantage — Investors can bypass early-withdrawal penalties on newly acquired shares, undermining the incentive alignment and economic design.

  • Economic distortion — Lockup is meant to stabilize share supply; bypassing it enables churn, potential run-on-the-bank conditions, and mispriced risk for other participants.

Proof of Concept

  • share_received_time is not updated after full withdrawal and reinvestment.

  • A withdrawal performed immediately after reinvestment can be considered past the lockup if enough time has elapsed since the original timestamp—resulting in no penalty on brand-new shares.

# tests/test_lockup_bypass.py
import boa
COMPANY_PATH = "src/Cyfrin_Hub.vy"
def test_lockup_bypass_after_full_withdrawal():
investor = boa.env.generate_address()
owner = boa.env.generate_address()
boa.env.set_balance(investor, 10**21)
boa.env.set_balance(owner, 10**21)
# Deploy company
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
# Seed solvency so fund_investor passes
company.fund_cyfrin(0, value=10**19)
# 1) First investment: sets share_received_time only once
with boa.env.prank(investor):
company.fund_cyfrin(1, value=10**18) # investor route -> mints shares
first_stamp = company.share_received_time(investor)
# Advance a short time (< LOCKUP_PERIOD)
boa.env.set_block_timestamp(first_stamp + (5 * 24 * 3600)) # +5 days
# 2) Withdraw all shares: penalty should apply (since < 30 days)
with boa.env.prank(investor):
# Expect early withdrawal penalty to reduce payout
company.withdraw_shares()
# Confirm shares zeroed
assert company.get_my_shares() == 0
# 3) Reinvest immediately: BUG — timestamp is not refreshed
# (share_received_time remains the old value since it's set only if == 0)
with boa.env.prank(investor):
company.fund_cyfrin(1, value=10**18)
# The timestamp is unchanged and still points to the original time
assert company.share_received_time(investor) == first_stamp, \
"Timestamp should have been refreshed on new share issuance but wasn't"
# 4) Immediate second withdrawal: lockup bypass — treated as > 30 days if enough time elapsed since first_stamp
# Advance the clock just enough so (now - first_stamp) >= 30 days,
# even though the new shares are only seconds old.
boa.env.set_block_timestamp(first_stamp + (31 * 24 * 3600)) # now >= 31 days since original time
balance_before = company.get_balance()
with boa.env.prank(investor):
company.withdraw_shares()
balance_after = company.get_balance()
# No early penalty applied even though the new shares were held for almost no time.
# If penalty were applied, payout would be 10% less; here we just assert that a withdrawal succeeds
# and that the timestamp wasn't refreshed.
assert balance_after <= balance_before, "Payout reduced company balance as expected"
print("[✓] PoC: Lockup penalty bypassed by withdrawing then reinvesting without timestamp refresh.")

Recommended Mitigation

  • Refresh the lockup timestamp on every net-positive share acquisition and clear/reset it on full withdrawal.

@payable
@internal
def fund_investor():
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!!!"
# ... compute share_price, 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
+ # Always refresh the timestamp when the user's share balance increases
+ # (You can choose to refresh only when balance crosses from 0 -> >0 if desired)
+ if new_shares > 0:
+ self.share_received_time[msg.sender] = block.timestamp
log SharesIssued(investor=msg.sender, amount=new_shares)
@external
def withdraw_shares():
shares_owned: uint256 = self.shares[msg.sender]
assert shares_owned > 0, "Not an investor!!!"
# ... compute payout, apply penalty if time_held < LOCKUP_PERIOD ...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
+ # Clear timestamp on full withdrawal so a new investment starts a fresh lockup
+ self.share_received_time[msg.sender] = 0
assert self.company_balance >= payout, "Insufficient company funds!!!"
self.company_balance -= payout
raw_call(msg.sender, b"", value=payout, revert_on_failure=True)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
3 days ago
0xshaedyw Lead Judge 1 day ago
Submission Judgement Published
Validated
Assigned finding tags:

High – Lockup Not Reset

`share_received_time` is only set once, allowing investors to bypass lockup on subsequent investments.

Support

FAQs

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