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!!!"
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
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
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
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.
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)
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
company.fund_cyfrin(0, value=10**19)
with boa.env.prank(investor):
company.fund_cyfrin(1, value=10**18)
first_stamp = company.share_received_time(investor)
boa.env.set_block_timestamp(first_stamp + (5 * 24 * 3600))
with boa.env.prank(investor):
company.withdraw_shares()
assert company.get_my_shares() == 0
with boa.env.prank(investor):
company.fund_cyfrin(1, value=10**18)
assert company.share_received_time(investor) == first_stamp, \
"Timestamp should have been refreshed on new share issuance but wasn't"
boa.env.set_block_timestamp(first_stamp + (31 * 24 * 3600))
balance_before = company.get_balance()
with boa.env.prank(investor):
company.withdraw_shares()
balance_after = company.get_balance()
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
@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)