Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: medium
Valid

Refund uses send() without checking result (lost refunds / stuck ETH)

Root + Impact

Description

  • Expected behavior: Any refund to a user is performed safely; if direct transfer fails, the contract should handle it (e.g., by crediting a withdrawable balance).


  • Actual behavior: send(msg.sender, excess) is called but its return is ignored — if send fails, the refund silently fails and funds remain stuck in the contract (or user loses expected refund).

send in Vyper returns bool but does not revert on failure. Not checking its result means refunds might fail quietly (e.g., if the recipient is a contract using >2300 gas or if gas stipend is insufficient).

// Root cause in the codebase with @> marks to highlight the relevant section
# Refund excess ETH
excess: uint256 = msg.value - total_cost
if excess > 0:
@> send(msg.sender, excess)

Risk

Likelihood

Medium — send failures occur when the recipient is a contract with non-trivial fallback or gas requirements.

Impact

1.Users may permanently lose expected refunds.

2.Funds can become trapped inside CustomerEngine and distort accounting.

3.A malicious user contract could lead to repeated failed refunds and eventual fund misallocation.

Proof of Concept

A tiny Solidity attacker contract that forces refunds to fail by reverting in its fallback (so send from CustomerEngine.vy returns false and the refund is lost).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Minimal PoC attacker that causes refunds (via send) to fail.
/// The attack flow:
/// 1. deploy this Attacker contract
/// 2. call attack(customerEngine) payable with value > 0 (e.g., ITEM_PRICE * 5 + extra)
/// 3. Attacker.forward calls CustomerEngine.trigger_demand() from the contract address
/// 4. When CustomerEngine attempts `send(msg.sender, excess)` the send will fail
/// because this contract's fallback reverts. CustomerEngine ignores the send return,
/// leaving the excess ETH inside CustomerEngine (refund lost).
contract RefundFailPoC {
// fallback reverts so any push-style refund (send) fails
receive() external payable {
revert("reject refunds");
}
/// @notice call trigger_demand() on the target customer engine
/// @param customerEngine address of CustomerEngine contract
function attack(address customerEngine) external payable {
require(customerEngine != address(0), "invalid target");
// call trigger_demand() with the attached value
(bool ok, bytes memory ret) = customerEngine.call{value: msg.value}(
abi.encodeWithSignature("trigger_demand()")
);
require(ok, "trigger_demand reverted");
}
/// @notice helper to withdraw any accidental ETH in this contract (should be none)
function withdraw(address to) external {
require(to != address(0), "invalid");
payable(to).transfer(address(this).balance);
}
}

Recommended Mitigation

Use a pull-refund pattern or check send return and fallback to credit:


- remove this code
+ add this code
- if excess > 0:
- send(msg.sender, excess)
+ if excess > 0:
+ success: bool = send(msg.sender, excess)
+ if not success:
+ # credit for later withdrawal (pull pattern)
+ failed_refunds[msg.sender] = failed_refunds.get(msg.sender, 0) + excess
Updates

Lead Judging Commences

0xshaedyw Lead Judge
4 days ago
0xshaedyw Lead Judge 3 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Medium – Refund DOS via send()

Vyper’s send() reverts on failure, so refund attempts to contract wallets with complex fallback logic can halt trigger_demand() entirely. This causes a denial-of-service for affected users but does not result in fund loss.

Appeal created

0xshaedyw Lead Judge
about 10 hours ago

Support

FAQs

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