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:
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.
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
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)
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
t0 = company.last_hold_time()
boa.env.set_block_timestamp(t0 + 30 * 24 * 3600)
with boa.env.prank(owner):
company.fund_cyfrin(0, value=10**19)
engine = boa.env.generate_address()
with boa.env.prank(owner):
company.set_customer_engine(engine)
company.produce(0)
before_debt = company.holding_debt()
with boa.env.prank(engine):
try:
company.sell_to_customer(1, value=0)
except boa.BoaError:
pass
after_debt = company.holding_debt()
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)
t1 = company.last_hold_time()
boa.env.set_block_timestamp(t1 + 3600)
with boa.env.prank(owner):
engine = boa.env.generate_address()
company.set_customer_engine(engine)
with boa.env.prank(engine):
company.sell_to_customer(5, value=0)
company.inventory = 0
t2 = company.last_hold_time()
boa.env.set_block_timestamp(t2 + 7 * 24 * 3600)
before_balance = company.get_balance()
before_debt = company.holding_debt()
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()
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