Company Simulator

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

[M-01] - ETH sent to `CustomerEngine.trigger_demand()` is stuck on sale failure

Root + Impact

Description

The CustomerEngine.vy contract's trigger_demand() function is responsible for initiating a sale and handling ETH transfers. When a customer calls this function, they must send the full purchase price (msg.value).

The function first refunds any excess ETH to the customer. It then makes a raw_call to Cyfrin_Hub.sell_to_customer with the calculated sale price.

If the sell_to_customer call fails (e.g., due to zero inventory, as confirmed by PoC), the raw_call reverts, but the ETH sent with the raw_call is returned to the CustomerEngine contract's balance. Since the CustomerEngine has no withdrawal function, this ETH becomes permanently stuck in the contract.

# Root cause in the codebase (CustomerEngine.vy)
@external
@payable
def trigger_demand():
# ...
# Calculate price and refund excess ETH
# ...
# Call Hub to execute sale
raw_call(
self.cyfrin_hub,
_data,
value=price, # @> ETH is sent with this call
max_gas=30000,
revert_on_failure=True
)
# ...
# No logic to refund 'price' ETH if raw_call fails

Risk

Likelihood: Medium
The exploit occurs under a specific, but common, condition: the Cyfrin_Hub must revert a sale. This happens whenever the company has zero inventory, which is a normal operational state (e.g., between production cycles). The attacker only needs to call the function when inventory is low to cause the revert and stick the ETH.

Impact: Medium
The amount of stuck ETH is limited to the sale price (price) for that specific transaction. While the protocol's core funds are not at risk, the contract's balance will accumulate stuck funds over time, leading to a loss of revenue for the company and an unexpected loss for the customer (who expects the sale to succeed or the funds to be returned).

Proof of Concept

The exploit was confirmed by a targeted PoC test:

  1. Setup: Deploy contracts. Owner funds Hub but does not produce inventory.

  2. Attack: Customer calls trigger_demand() with 1 ETH. Sale price is 0.02 ETH.

  3. Execution:

    • CustomerEngine refunds 0.98 ETH (excess).

    • CustomerEngine calls sell_to_customer with 0.02 ETH.

    • sell_to_customer reverts due to zero inventory.

    • The 0.02 ETH is returned to the CustomerEngine contract.

  4. Result: The CustomerEngine contract's balance increases by 0.02 ETH, which is now permanently stuck.

Supporting Code:

# Test to demonstrate M-01: ETH Stuck in CustomerEngine on Revert
import boa
from eth_utils import to_wei
def test_eth_stuck_in_customer_engine():
"""
Demonstrates that ETH sent to CustomerEngine.trigger_demand()
is stuck in the contract when the sale fails.
"""
# Deploy contracts
industry = boa.load("src/Cyfrin_Hub.vy")
engine = boa.load("src/CustomerEngine.vy", industry.address)
# Setup
owner = industry.OWNER_ADDRESS()
customer = boa.env.generate_address("customer")
boa.env.set_balance(owner, to_wei(1000, "ether"))
boa.env.set_balance(customer, to_wei(10, "ether"))
# Owner sets engine and funds company (but does NOT produce inventory)
with boa.env.prank(owner):
industry.set_customer_engine(engine.address)
industry.fund_cyfrin(0, value=to_wei(100, "ether"))
# Verify: Company has funds but zero inventory
assert industry.company_balance() == to_wei(100, "ether")
assert industry.inventory() == 0
# Record initial balances
engine_balance_before = boa.env.get_balance(engine.address)
customer_balance_before = boa.env.get_balance(customer)
# Attack: Customer triggers demand with 1 ETH
# Sale price is 0.02 ETH, so 0.98 ETH should be refunded
with boa.env.prank(customer):
try:
engine.trigger_demand(value=to_wei(1, "ether"))
except Exception as e:
# Sale will fail due to zero inventory
pass
# Verify: Customer received the excess refund (0.98 ETH)
customer_balance_after = boa.env.get_balance(customer)
refund = customer_balance_after - (customer_balance_before - to_wei(1, "ether"))
# Verify: The sale price (0.02 ETH) is now stuck in the CustomerEngine
engine_balance_after = boa.env.get_balance(engine.address)
stuck_eth = engine_balance_after - engine_balance_before
# Assertions
assert stuck_eth > 0, f"ETH should be stuck in CustomerEngine, but balance is {stuck_eth}"
assert stuck_eth == to_wei(0.02, "ether"), f"Expected 0.02 ETH stuck, got {stuck_eth / 10**18} ETH"
print(f"✓ PoC Confirmed: {stuck_eth / 10**18} ETH is permanently stuck in CustomerEngine")

Recommended Mitigation

The CustomerEngine must check the result of the raw_call and, if it fails, refund the price ETH back to the msg.sender.

@external
@payable
def trigger_demand():
# ...
# Call Hub to execute sale
success: bool = raw_call(
self.cyfrin_hub,
_data,
value=price,
max_gas=30000,
revert_on_failure=False # Must be False to handle failure manually
)
if not success:
# Mitigation: Refund the sale price if the call to the Hub failed
send(msg.sender, price)
# Mitigation: Revert the transaction to undo state changes and inform user
raise "Sale failed and funds refunded"
# ... rest of the function (reputation update, etc.)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
7 days ago
0xshaedyw Lead Judge 6 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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