Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: low
Likelihood: medium
Invalid

# L-1: `withdraw` uses `.transfer` and emits no event

Root + Impact

withdraw() forwards the contract's entire ETH balance to an owner-chosen target using transfer, which hard-codes a 2300-gas stipend. Any recipient whose receive/fallback (e.g., a multisig such as a Safe, or any treasury contract) needs more than 2300 gas will revert, so proceeds can only ever be withdrawn to an EOA. The declared FundsWithdrawn event is also never emitted, removing on-chain auditability of the protocol's primary money flow.

Description

The function uses the deprecated .transfer and drops the success value by construction (transfer reverts on failure). Correct handling would be call{value: ...} with an explicit success check. The event defined in the interface (IFestivalPass.sol:78) is unused.

// src/FestivalPass.sol:147-149
function withdraw(address target) external onlyOwner {
@> payable(target).transfer(address(this).balance);
}

Risk

Likelihood: Medium. It manifests as soon as the owner routes proceeds to a contract treasury/multisig rather than an EOA — a normal operational choice.

Impact: If the intended target is a contract that requires more than 2300 gas, withdrawal reverts and proceeds are temporarily stuck until the owner switches to an EOA. Owner-controlled, so not a fund loss, but it breaks the expected treasury flow and lacks any event trail for accounting.

Proof of Concept

A non-payable, non-receiving contract is deployed as the withdrawal target. After a pass sale the contract holds ETH; withdraw to the sink reverts because the 2300-gas transfer cannot complete.

// test/Exploits.t.sol
contract NoReceive { /* deliberately no receive()/fallback() */ }
​
function test_WithdrawRevertsForContractTarget() public {
address buyer = makeAddr("buyer");
vm.deal(buyer, 1 ether);
vm.prank(buyer);
fp.buyPass{value: GENERAL_PRICE}(1);
​
NoReceive sink = new NoReceive();
vm.expectRevert(); // 2300-gas transfer to a non-receiver reverts
fp.withdraw(address(sink)); // caller is owner (this test)
}

Recommended Mitigation

Use call with an explicit success check (or Address.sendValue) and emit FundsWithdrawn. Consider pulling to a configured treasury address instead of an arbitrary per-call target.

function withdraw(address target) external onlyOwner {
- payable(target).transfer(address(this).balance);
+ uint256 amount = address(this).balance;
+ (bool ok, ) = payable(target).call{value: amount}("");
+ require(ok, "ETH transfer failed");
+ emit FundsWithdrawn(target, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!