Company Simulator

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

Hold-time timer should be initialized to 0 and paused when inventory is 0

Author Revealed upon completion

Description

  • The company accrues holding costs over time for items in inventory. The contract tracks last_hold_time and, on each sale, applies the cost accrued since the previous update.

  • Two related problems cause unintended debt accumulation:

    1. Constructor sets last_hold_time = block.timestamp. If the company remains idle for a long time after deployment (no production, no sales), the first business interaction that calls _apply_holding_cost() will charge holding costs for the entire elapsed period—even though there may have been no items stored (inventory 0) or no operational activity.

    2. Holding cost accrues even when inventory == 0. The code multiplies by inventory, but still advances the timer and records “paid” time, meaning future costs will include idle stretches; moreover, it doesn’t guard against advancing last_hold_time only when inventory is non‑zero, so the timer is never paused when the warehouse is empty, and long idle periods can be counted incorrectly.

// Root cause in the codebase with @> marks to highlight the relevant section
// File: Cyfrin_Hub.vy (CompanyGame)
@deploy
def __init__():
OWNER = msg.sender
self.inventory = 0
self.reputation = 100
self.last_hold_time = block.timestamp // @> Starts timer immediately on deployment
self.public_shares_cap = 1_000_000
self.issued_shares = 0
@internal
def _apply_holding_cost():
seconds_passed: uint256 = block.timestamp - self.last_hold_time
cost_per_second: uint256 = HOLDING_COST_PER_ITEM // 3600
cost: uint256 = (seconds_passed * self.inventory * cost_per_second)
// @> No guard for empty inventory; timer always advances
if self.company_balance >= cost:
self.company_balance -= cost
else:
self.holding_debt += cost - self.company_balance
self.company_balance = 0
self.last_hold_time = block.timestamp

Risk

Likelihood: High

  • Occurs when the contract sits idle after deployment (no items produced, no sales) and the first call that applies holding cost happens much later.

  • Occurs when inventory drops to zero (sold out or not yet produced) but _apply_holding_cost() continues advancing last_hold_time, incorrectly counting idle time toward future costs.

Impact: High

  • Unfair / phantom debt — The company accrues holding debt for periods where no items were stored or business hadn’t started, penalizing legitimate operations and blocking investor funding (insolvency checks).

  • Operational distortion — Production or sales may become unexpectedly expensive or even revert (due to insufficient company_balance) because of inflated holding costs, creating DoS-like conditions for normal users.

Proof of Concept

# tests/test_hold_timer_idle_and_empty.py
import boa
COMPANY_PATH = "src/Cyfrin_Hub.vy"
def test_idle_after_deploy_causes_phantom_cost():
owner = boa.env.generate_address()
boa.env.set_balance(owner, 10**21)
# Deploy company at time T0; inventory = 0, last_hold_time = block.timestamp (T0)
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
# Advance time by a large duration without any items produced
t0 = company.last_hold_time()
boa.env.set_block_timestamp(t0 + 30 * 24 * 3600) # +30 days
# Seed balance; then trigger any function that applies holding cost (e.g., a sale path)
with boa.env.prank(owner):
company.fund_cyfrin(0, value=10**19) # add funds
# Simulate a sale by setting a customer engine EOA and calling sell_to_customer
engine = boa.env.generate_address()
with boa.env.prank(owner):
company.set_customer_engine(engine)
company.produce(0) # still zero inventory
# Now calling sell_to_customer applies holding cost.
# Since inventory is 0, cost should intuitively be 0, but timer logic advances anyway.
before_debt = company.holding_debt()
with boa.env.prank(engine):
# call with requested=0 equivalent path doesn't exist; we call with any value but inventory 0 triggers reputation path.
try:
company.sell_to_customer(1, value=0)
except boa.BoaError:
pass # not critical; we only want to observe debt/time behavior
after_debt = company.holding_debt()
# If timer started at deploy and advanced across 30 days, wrong implementations may change debt/time.
# Proper fix should keep debt unchanged when inventory == 0.
assert after_debt == before_debt, "Holding debt should not grow when inventory == 0 during idle period"
def test_timer_pauses_when_inventory_zero():
owner = boa.env.generate_address()
boa.env.set_balance(owner, 10**21)
with boa.env.prank(owner):
Company = boa.load(COMPANY_PATH)
company = Company.deploy()
company.fund_cyfrin(0, value=10**19)
company.produce(10) # add items
# Advance some time and apply cost once
t1 = company.last_hold_time()
boa.env.set_block_timestamp(t1 + 3600)
with boa.env.prank(owner):
# Any path applying holding cost; we simulate a sale via engine
engine = boa.env.generate_address()
company.set_customer_engine(engine)
with boa.env.prank(engine):
company.sell_to_customer(5, value=0) # inventory check may fail; we just want to exercise _apply_holding_cost
# Sell out / set inventory to zero (simulate via direct storage for the test)
# In real flow, consecutive sales would reduce inventory; here we force it:
company.inventory = 0
# Advance a long time with empty inventory
t2 = company.last_hold_time()
boa.env.set_block_timestamp(t2 + 7 * 24 * 3600) # +7 days
before_balance = company.get_balance()
before_debt = company.holding_debt()
# Apply holding cost again through any path
with boa.env.prank(engine):
try:
company.sell_to_customer(1, value=0)
except boa.BoaError:
pass
after_balance = company.get_balance()
after_debt = company.holding_debt()
# With the suggested pause semantics, no cost accrues when inventory == 0
assert after_balance == before_balance, "Balance should not be reduced when inventory == 0"
assert after_debt == before_debt, "Debt should not grow when inventory == 0"

Recommended Mitigation

Initialize the timer to 0 so that the first holding cost application establishes the start of measurable storage time only when inventory is non‑zero. Also pause the timer when inventory == 0.

@deploy
def __init__():
- self.last_hold_time = block.timestamp
+ # Do not start the holding timer until there are items to hold
+ self.last_hold_time = 0@internal
def _apply_holding_cost():
- seconds_passed: uint256 = block.timestamp - self.last_hold_time
- cost_per_second: uint256 = HOLDING_COST_PER_ITEM // 3600
- cost: uint256 = (seconds_passed * self.inventory * cost_per_second)
- if self.company_balance >= cost:
- self.company_balance -= cost
- else:
- self.holding_debt += cost - self.company_balance
- self.company_balance = 0
- self.last_hold_time = block.timestamp
+ # If no items, do not accrue and keep/clear the timer paused.
+ if self.inventory == 0:
+ # Pause semantics: do not advance the timer while empty.
+ # Optionally, reset to 0 to mark "no active holding".
+ self.last_hold_time = 0
+ return
+
+ # Start the timer on first use when inventory becomes non-zero
+ if self.last_hold_time == 0:
+ self.last_hold_time = block.timestamp
+ return # No prior period to charge on the very first activation
+
+ seconds_passed: uint256 = block.timestamp - self.last_hold_time
+ cost_per_second: uint256 = HOLDING_COST_PER_ITEM // 3600
+ # Safe multiplication order and guards to reduce overflow risk on large values
+ per_item_cost: uint256 = seconds_passed * cost_per_second
+ cost: uint256 = per_item_cost * self.inventory
+
+ if self.company_balance >= cost:
+ self.company_balance -= cost
+ else:
+ self.holding_debt += cost - self.company_balance
+ self.company_balance = 0
+
+ # Advance the timer only while inventory is non-zero
+ self.last_hold_time = block.timestamp
Updates

Lead Judging Commences

0xshaedyw Lead Judge
3 days ago
0xshaedyw Lead Judge 1 day ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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