Company Simulator

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

Owner Debt Overpayment Lock

Author Revealed upon completion

Root + Impact

Description

  • Cyfrin_Hub.pay_holding_debt() credits excess ETH to company_balance without owner withdrawal path. When owner overpays, excess is permanently locked; race condition with _apply_holding_cost() forces overpayment.

```vyper
@payable
@external
def pay_holding_debt():
@> assert msg.value >= self.holding_debt, "Not enough to pay debt!!!"
@> self.holding_debt = 0 # Debt cleared regardless of overpayment
@> self.company_balance += msg.value # Full payment taken, no refund
@> # ← NO REFUND for excess payment
```

Risk

Likelihood: Medium

  • Owners may overpay to ensure full debt coverage (avoiding partial payment scenario). No refund mechanism means any overpayment is permanently trapped.

Impact: High

  • Direct owner loss; excess ETH permanently locked; affects protocol capital management.

Proof of Concept

  • Overview: Python test test_poc_005_owner_debt_overpayment.py demonstrates owner losing ETH when overpaying holding debt. Time-based holding costs accumulate debt, owner overpays by 1 ETH, and excess is permanently trapped in company_balance.

  • Step-by-step:

    1. Setup: Owner funds company with 5 ETH, produces 10 items, then fast-forwards 2000 hours to accumulate holding debt.

    2. Attack Vector: Owner calls pay_holding_debt() with holding_debt + 1 ETH (intentional overpayment).

    3. Execution Flow: Contract clears holding_debt to zero using the debt portion of payment, and credits the excess (1 ETH overpayment) to company_balance.

    4. Result: Excess 1 ETH trapped in company_balance; owner has no shares and no withdrawal path to recover funds.

"""
POC-005: Owner Debt Overpayment Lock
Severity: HIGH | Likelihood: Medium | Impact: High
Proves that `pay_holding_debt()` credits excess owner payments to
`company_balance` with no owner withdrawal path once `holding_debt`
is cleared, permanently trapping capital.
"""
# ============================================================================
# EXECUTION CONTEXT
# ============================================================================
# This PoC uses pytest fixtures from conftest.py that:
# - Deploy original protocol contracts: industry_contract (Cyfrin_Hub.vy)
# - Provide funded test accounts: OWNER (deployer who overpays debt),
# PATRICK (used as mock customer engine to accrue holding costs)
#
# Framework: titanoboa (Python testing framework for Vyper smart contracts)
# Dependencies: pytest, titanoboa, eth_utils
# ============================================================================
import boa
from eth_utils import to_wei
PRODUCTION_BATCH = 10
HOURS_TO_ACCUMULATE = 2000
EXTRA_PAYMENT = to_wei(1, "ether")
def _accrue_holding_debt(contract, owner, engine):
with boa.env.prank(owner):
contract.set_customer_engine(engine)
boa.env.time_travel(seconds=HOURS_TO_ACCUMULATE * 3600)
with boa.env.prank(engine):
contract.sell_to_customer(0)
def test_poc_005_owner_debt_overpayment_lock(industry_contract, OWNER, PATRICK):
"""
Scenario:
- Owner funds company and produces inventory
- Time passes so holding costs exceed company balance, creating debt
- Owner overpays when calling `pay_holding_debt`
- Excess ETH is credited to `company_balance` and remains trapped
"""
boa.env.set_balance(OWNER, to_wei(100, "ether"))
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=to_wei(5, "ether"))
with boa.env.prank(OWNER):
industry_contract.produce(PRODUCTION_BATCH)
_accrue_holding_debt(industry_contract, OWNER, PATRICK)
holding_debt_before = industry_contract.holding_debt()
company_balance_before = industry_contract.company_balance()
owner_shares_before = industry_contract.shares(OWNER)
assert holding_debt_before > 0
assert company_balance_before == 0
assert owner_shares_before == 0
payment = holding_debt_before + EXTRA_PAYMENT
with boa.env.prank(OWNER):
industry_contract.pay_holding_debt(value=payment)
holding_debt_after = industry_contract.holding_debt()
company_balance_after = industry_contract.company_balance()
owner_shares_after = industry_contract.shares(OWNER)
trapped = company_balance_after - company_balance_before
assert holding_debt_after == 0
assert company_balance_after == EXTRA_PAYMENT
assert owner_shares_after == 0
assert trapped == EXTRA_PAYMENT
print("\n<<< POC-005 >>>")
print(f"holding_debt_before={holding_debt_before} wei")
print(f"company_balance_before={company_balance_before} wei")
print(f"extra_payment={EXTRA_PAYMENT} wei")
print(f"company_balance_after={company_balance_after} wei")
print(f"trapped_owner_capital={trapped} wei")
print("[fail] pay_holding_debt refunds=0; excess credited to company_balance")

Recommended Mitigation

  • Refund excess payment to prevent owner ETH lock, or revert on overpayment to force exact amounts.

# Option 1: Refund excess (RECOMMENDED)
- assert msg.value >= self.holding_debt, "Not enough to pay debt!!!"
- self.holding_debt = 0
- self.company_balance += msg.value
+ debt_to_pay: uint256 = min(msg.value, self.holding_debt)
+ excess: uint256 = msg.value - debt_to_pay
+ self.holding_debt -= debt_to_pay
+ self.company_balance += debt_to_pay
+ if excess > 0:
+ raw_call(msg.sender, b"", value=excess, revert_on_failure=True)
# Option 2: Revert on overpayment
- assert msg.value >= self.holding_debt, "Not enough to pay debt!!!"
+ assert msg.value == self.holding_debt, "Exact payment required"
self.holding_debt = 0
self.company_balance += msg.value
Updates

Lead Judging Commences

0xshaedyw Lead Judge
4 days ago
0xshaedyw Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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