Description
-
With the intended CustomerEngine caller, trigger_demand() enforces payment (msg.value >= total_cost), refunds any excess, and forwards exactly total_cost to CompanyGame.sell_to_customer(requested).
-
CompanyGame.sell_to_customer() credits self.company_balance += requested * SALE_PRICE without asserting the actual ETH received (msg.value). If CUSTOMER_ENGINE is set to a wrong address (EOA or a malicious/buggy contract) or is later replaced/compromised, it can call sell_to_customer() with less or zero ETH and still cause internal revenue and reputation to increase—creating accounting drift and false solvency.
// Root cause in the codebase with @> marks to highlight the relevant section
// File: Cyfrin_Hub.vy (CompanyGame)
@external
@payable
def sell_to_customer(requested: uint256):
assert msg.sender == self.CUSTOMER_ENGINE, "Not the customer engine!!!"
assert not self._is_bankrupt(), "Company is bankrupt!!!"
self._apply_holding_cost()
if self.inventory >= requested:
self.inventory -= requested
revenue: uint256 = requested * SALE_PRICE
// @> BUG: credits internal balance using computed revenue
// @> without checking the actual ETH sent (msg.value)
self.company_balance += revenue
if self.reputation < 100:
self.reputation = min(self.reputation + REPUTATION_REWARD, 100)
else:
self.reputation = 100
log Sold(amount=requested, revenue=revenue)
else:
self.reputation = min(max(self.reputation - REPUTATION_PENALTY, 0), 100)
log ReputationChanged(new_reputation=self.reputation)
Risk
Likelihood (under misconfiguration/compromise): Low
-
Occurs when CUSTOMER_ENGINE is set to a non‑conforming caller (e.g., EOA) or a contract that forwards the wrong value; the current set_customer_engine does not verify code size and allows any address once.
-
Occurs when the trusted engine later changes behavior (upgrade proxy, bug, or malicious actor) and calls sell_to_customer() with underpayment.
Impact: Medium
-
False solvency / accounting drift — company_balance inflates without matching ETH, causing downstream reverts (withdrawals, production) and misleading “net worth” calculations.
-
Reputation pumping — “Successful” sales increase reputation even if no ETH was actually paid, weakening demand gating in the engine.
POC
This PoC does not use the provided CustomerEngine. It demonstrates misconfiguration by setting CUSTOMER_ENGINE to an EOA and calling with zero value.
import boa
COMPANY_PATH = "src/Cyfrin_Hub.vy"
SALE_PRICE = 2 * 10**16
REQUESTED = 3
def main():
owner = boa.env.generate_address()
engine = boa.env.generate_address()
for a in (owner, engine):
boa.env.set_balance(a, 10**21)
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
company.set_customer_engine(engine)
company.fund_cyfrin(0, value=10**19)
company.produce(100)
base_internal = company.get_balance()
base_real = boa.env.get_balance(company.address)
with boa.env.prank(engine):
company.sell_to_customer(REQUESTED, value=0)
after_internal = company.get_balance()
after_real = boa.env.get_balance(company.address)
expected_add = SALE_PRICE * REQUESTED
assert after_internal == base_internal + expected_add
assert after_real == base_real
print(f"[✓] Misconfig PoC: internal +{expected_add} without ETH received")
print(f" internal: {base_internal} -> {after_internal}")
print(f" real ETH: {base_real} -> {after_real}")
if __name__ == "__main__":
main()
Recommended Mitigation
Even if the Author keeps the current engine design, it's recommended to make sell_to_customer() payment-aware and tighten configuration:
@external
@payable
def sell_to_customer(requested: uint256):
assert msg.sender == self.CUSTOMER_ENGINE, "Not the customer engine!!!"
assert not self._is_bankrupt(), "Company is bankrupt!!!"
self._apply_holding_cost()
if self.inventory >= requested:
revenue: uint256 = requested * SALE_PRICE
+ # Enforce exact payment to prevent accounting drift
+ assert msg.value == revenue, "Incorrect payment for requested items"
- self.company_balance += revenue
+ self.company_balance += msg.value
self.inventory -= requested
# reputation update unchanged...