Beatland Festival

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

Use of `.transfer()` can cause permanent fund lock if withdrawal target is a smart contract wallet

[M-2] Use of .transfer() can cause permanent fund lock if withdrawal target is a smart contract wallet

Description

The withdraw() function uses the deprecated .transfer() method to send ETH to the target address:

function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance);
}

The .transfer() method has a hardcoded gas stipend of 2,300 gas, which is insufficient for:

  • Multi-signature wallets (Gnosis Safe, etc.)

  • Smart contract wallets with custom logic in their receive() or fallback() functions

  • Any contract with fallback/receive functions that write to storage

  • Proxy contracts that forward calls

  • Contracts that emit events on ETH receipt

If the owner attempts to withdraw to a smart contract wallet (increasingly common for security best practices), the withdrawal will always fail, causing permanent fund lock as there is no alternative withdrawal mechanism.

Impact

MEDIUM-HIGH - Can result in permanent loss of all festival revenue:

  1. Permanent Fund Lock: If the withdrawal target is a smart contract wallet requiring >2,300 gas, all ETH from pass sales becomes permanently stuck in the contract

  2. No Recovery Mechanism: The contract has no alternative withdrawal method or emergency function

  3. Common in Production: Many security-conscious projects use multi-sig wallets (Gnosis Safe, multi-owner wallets) for fund management

  4. Future-Proofing Risk: EVM gas cost changes (like EIP-1884 in 2019) can break .transfer() even for previously working contracts

  5. Lost Revenue: Festival organizers lose all ticket sales revenue with no way to recover funds

Real-world scenarios:

  • Owner deploys with intention to withdraw to a Gnosis Safe (security best practice)

  • Festival sells thousands of passes, collecting substantial ETH (e.g., 100 ETH)

  • Owner calls withdraw(gnosisSafeAddress)

  • Transaction fails due to out-of-gas

  • Funds permanently locked with no recovery possible

Proof of Concept

// Smart contract wallet that requires >2,300 gas to receive ETH
contract SmartWallet {
uint256 public transactionCount;
receive() external payable {
// Storage write costs ~20,000 gas (far exceeds 2,300 gas limit)
transactionCount++;
}
}
function test_Withdraw_FailsWithSmartContractWallet() public {
// Deploy a smart contract wallet
SmartWallet wallet = new SmartWallet();
// Users buy passes, contract accumulates ETH
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
vm.prank(user2);
festivalPass.buyPass{value: VIP_PRICE}(2);
uint256 totalBalance = GENERAL_PRICE + VIP_PRICE;
assertEq(address(festivalPass).balance, totalBalance);
// Owner tries to withdraw to smart contract wallet
vm.prank(owner);
vm.expectRevert(); // Fails with out-of-gas error
festivalPass.withdraw(address(wallet));
// Funds are permanently stuck!
assertEq(address(festivalPass).balance, totalBalance);
assertEq(address(wallet).balance, 0);
}
function test_Withdraw_SucceedsWithEOA() public {
// Withdraw to EOA (Externally Owned Account) works fine
vm.prank(user1);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
address eoaTarget = makeAddr("eoaTarget");
vm.prank(owner);
festivalPass.withdraw(eoaTarget); // Succeeds
assertEq(address(eoaTarget).balance, GENERAL_PRICE);
assertEq(address(festivalPass).balance, 0);
}

Gas Analysis:

// .transfer() provides only 2,300 gas
// Common operations that exceed this limit:
SSTORE (new slot): 20,000 gas ❌ Exceeds limit by 8.7x
SSTORE (modify): 5,000 gas ❌ Exceeds limit by 2.2x
Event emission: 375 gas per topic
External CALL: 700 gas base
Proxy forwarding: 3,000+ gas ❌ Exceeds limit
Multi-sig validation: 10,000+ gas ❌ Exceeds limit
// Smart contract wallets typically need 5,000-20,000 gas

Historical Context:

EIP-1884 (Istanbul hard fork, 2019) increased the gas cost of the SLOAD opcode from 200 to 800 gas, breaking many contracts that relied on .transfer() and .send(). This demonstrates that even if a contract works today, future EVM changes can break it.

Recommended Mitigation

Replace .transfer() with the modern .call() pattern with proper error handling:

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

Why this is better:

  1. Forwards all available gas instead of just 2,300

  2. Compatible with smart contract wallets (Gnosis Safe, Argent, etc.)

  3. Compatible with multi-signature wallets

  4. Future-proof against EVM gas cost changes

  5. Still safe due to onlyOwner modifier (no reentrancy risk since owner is trusted)

  6. Follows current Solidity best practices (post-2019)

Additional Improvements:

Add event emission for transparency and off-chain tracking:

event FundsWithdrawn(address indexed target, uint256 amount);
function withdraw(address target) external onlyOwner {
require(target != address(0), "Invalid target address");
uint256 amount = address(this).balance;
(bool success, ) = payable(target).call{value: amount}("");
require(success, "ETH transfer failed");
emit FundsWithdrawn(target, amount);
}

Note on Reentrancy:

Since this function is onlyOwner, reentrancy is not a concern here. The owner is trusted and would not deploy a malicious contract to attack their own funds. However, if paranoid, you could add a nonReentrant modifier from OpenZeppelin's ReentrancyGuard.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 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!