Company Simulator

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

Customer Funds Lost on Failed Sales

Author Revealed upon completion

Root + Impact

Description

  • Failed branch inside Cyfrin_Hub.sell_to_customer accepts ETH forwarded by the customer engine when inventory is empty, never refunding or accounting for the payment, so every failed sale traps the entire cart value.

```vyper
@external
@payable
def sell_to_customer(requested: uint256):
...
if self.inventory >= requested:
self.inventory -= requested
revenue: uint256 = requested * SALE_PRICE
self.company_balance += revenue
...
log Sold(amount=requested, revenue=revenue)
@> else:
@> self.reputation = min(max(self.reputation - REPUTATION_PENALTY, 0), 100)
@> log ReputationChanged(new_reputation=self.reputation)
@> # ← NO REFUND of msg.value when sale fails
```

Risk

Likelihood: High

  • Zero-inventory states occur on every fresh deployment and whenever production lags demand, so the failure branch is frequently reachable.

Impact: High

  • Each failed purchase strands the full cart payment (requested * 0.02 ETH) with no contract path to return it to the buyer or company accounting.

Proof of Concept

  • Overview: Python test tests/unit/test_poc_001_customer_funds_lost.py triggers a demand with zero inventory, confirms raw balance tracks the payment, and proves withdraw_shares() is inaccessible to customers.

  • Step-by-step:

    1. Setup: Ensure inventory=0, reputation=100, and fund the customer with 5 ETH.

    2. Attack Vector: Fast-forward one cooldown, invoke CustomerEngine.trigger_demand to forward the payment to sell_to_customer.

    3. Execution Flow: Observe reputation drop, zero inventory, and contract balance increase equals customer payment while company_balance stays zero.

    4. Result: Assert trapped delta equals payment and show withdraw_shares() reverts, leaving ETH irrecoverable.

"""
POC-001: Customer Funds Lost on Failed Sales
Severity: HIGH | Likelihood: High | Impact: High
Deterministic reproduction that customers permanently lose ETH when
`Cyfrin_Hub.sell_to_customer()` is invoked with insufficient inventory.
The function is `@payable`, accepts funds forwarded by `CustomerEngine`,
but never refunds when the inventory check fails. The ETH remains in
contract balance while `company_balance` accounting stays unchanged.
"""
# ============================================================================
# EXECUTION CONTEXT
# ============================================================================
# This PoC uses pytest fixtures from conftest.py that:
# - Deploy original protocol contracts: industry_contract (Cyfrin_Hub.vy),
# customer_engine_contract (CustomerEngine.vy)
# - Provide funded test accounts: OWNER (deployer), PATRICK (customer)
#
# Framework: titanoboa (Python testing framework for Vyper smart contracts)
# Dependencies: pytest, titanoboa, eth_utils
# ============================================================================
import boa
from eth_utils import keccak, to_wei
# Constants derived from CustomerEngine.vy
ITEM_PRICE = to_wei(0.02, "ether")
COOLDOWN = 60
def _addr_to_int(addr) -> int:
if isinstance(addr, int):
return addr
if hasattr(addr, "hex"):
addr_hex = addr.hex()
else:
addr_hex = str(addr)
if addr_hex.startswith("0x"):
addr_hex = addr_hex[2:]
return int(addr_hex, 16)
def _predicted_request(timestamp: int, customer, reputation: int) -> int:
"""Mirror CustomerEngine demand calculation for logging/debug."""
seed_bytes = keccak(
timestamp.to_bytes(32, "big")
+ _addr_to_int(customer).to_bytes(32, "big")
)
seed = int.from_bytes(seed_bytes, "big")
base = seed % 5
extra = 1 if (seed % 100) < (reputation - 50) else 0
requested = base + 1 + extra
return min(requested, 5)
def test_poc_001_customer_eth_stuck_on_failed_sale(
industry_contract,
customer_engine_contract,
OWNER,
PATRICK,
):
"""
Scenario: Company has zero inventory but reputation is high enough to
attract demand. CustomerEngine forwards ETH to Cyfrin_Hub. The sale
fails, yet Cyfrin_Hub keeps the ETH, never refunding and never crediting
`company_balance`.
"""
# --- Preconditions -----------------------------------------------------
# Ensure pristine state for accounting variables
assert industry_contract.inventory() == 0
assert industry_contract.company_balance() == 0
assert industry_contract.reputation() == 100
boa.env.set_balance(PATRICK, to_wei(5, "ether"))
# Cooldown enforcement: first demand must occur at timestamp > COOLDOWN
boa.env.time_travel(seconds=COOLDOWN + 5)
ts_before = boa.env.timestamp
predicted = _predicted_request(ts_before, PATRICK, industry_contract.reputation())
customer_balance_before = boa.env.get_balance(PATRICK)
contract_balance_before = boa.env.get_balance(industry_contract.address)
company_balance_before = industry_contract.company_balance()
reputation_before = industry_contract.reputation()
payment_ceiling = ITEM_PRICE * 5 # cover max demand (5 items)
# --- Exploit -----------------------------------------------------------
with boa.env.prank(PATRICK):
customer_engine_contract.trigger_demand(value=payment_ceiling)
# --- Post-state capture ------------------------------------------------
ts_after = boa.env.timestamp
customer_balance_after = boa.env.get_balance(PATRICK)
contract_balance_after = boa.env.get_balance(industry_contract.address)
company_balance_after = industry_contract.company_balance()
inventory_after = industry_contract.inventory()
reputation_after = industry_contract.reputation()
paid_by_customer = customer_balance_before - customer_balance_after
eth_stuck = (contract_balance_after - company_balance_after) - (
contract_balance_before - company_balance_before
)
# --- Assertions --------------------------------------------------------
assert paid_by_customer > 0, "Customer must lose ETH"
assert company_balance_after == company_balance_before == 0, (
"Accounting must ignore failed sale"
)
assert inventory_after == 0, "Inventory unchanged because sale failed"
assert reputation_after == reputation_before - 5, "Failure penalty applied"
assert contract_balance_after - contract_balance_before == paid_by_customer, (
"All ETH forwarded to hub contract"
)
assert eth_stuck == paid_by_customer, (
"Entire payment stuck (self.balance - company_balance)"
)
with boa.reverts():
with boa.env.prank(PATRICK):
industry_contract.withdraw_shares()
# --- Human-readable evidence ------------------------------------------
trapped_balance = contract_balance_after - company_balance_after
print("\n<<< POC-001 >>>")
print(f"timestamp_before={ts_before}")
print(f"timestamp_after={ts_after}")
print(f"predicted_request={predicted} items")
print(f"customer_paid={paid_by_customer} wei")
print(f"hub_contract_balance={contract_balance_after} wei")
print(f"company_accounting_balance={company_balance_after} wei")
print(f"reputation_drop={reputation_before - reputation_after}")
print(f"eth_stuck={eth_stuck} wei")
print(
f"state_snapshot="
f"{{'raw_balance': {contract_balance_after}, "
f"'accounting_balance': {company_balance_after}, "
f"'trapped_delta': {trapped_balance}}}"
)
print("[fail] sell_to_customer refunds=0; ETH trapped")

Recommended Mitigation

  • Revert the sale when inventory is insufficient so the engine caller retains funds, or proactively refund msg.value before updating reputation.

  • Record stuck ETH when the branch triggers (e.g., emit an event) to help ops reconcile historical incidents.

  • Add production monitoring so inventories cannot silently fall to zero.

- else:
- self.reputation = min(max(self.reputation - REPUTATION_PENALTY, 0), 100)
- log ReputationChanged(new_reputation=self.reputation)
+ else:
+ if msg.value > 0:
+ raw_call(
+ msg.sender,
+ b"",
+ value=msg.value,
+ revert_on_failure=True,
+ )
+ self.reputation = min(max(self.reputation - REPUTATION_PENALTY, 0), 100)
+ log ReputationChanged(new_reputation=self.reputation)

Support

FAQs

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