Summary
The ChristmasDinner contract uses .transfer()
for ETH refunds, limiting gas to 2300 units. This can permanently lock funds when recipients are smart contract wallets requiring >2300 gas.
Vulnerability Details
src/ChristmasDinner.sol#233-238
function _refundETH(address payable _to) internal {
uint256 refundValue = etherBalance[_to];
_to.transfer(refundValue);
etherBalance[_to] = 0;
}
Proof of concept
pragma solidity 0.8.27;
import {Test, console} from "forge-std/Test.sol";
import {ChristmasDinner} from "../src/ChristmasDinner.sol";
import {ERC20Mock} from "../lib/openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol";
contract GasHungryWallet {
uint256 public counter;
receive() external payable {
counter += 1;
}
function getGasLeft() external view returns (uint256) {
return gasleft();
}
}
contract ChristmasDinnerETHTest is Test {
ChristmasDinner cd;
ERC20Mock wbtc;
ERC20Mock weth;
ERC20Mock usdc;
GasHungryWallet wallet;
function setUp() public {
wbtc = new ERC20Mock();
weth = new ERC20Mock();
usdc = new ERC20Mock();
cd = new ChristmasDinner(address(wbtc), address(weth), address(usdc));
wallet = new GasHungryWallet();
cd.setDeadline(7 days);
}
function test_ETHTransferGasLimit() public {
vm.deal(address(wallet), 2 ether);
vm.prank(address(wallet));
(bool success,) = address(cd).call{value: 1 ether}("");
assertTrue(success, "Deposit should succeed");
assertEq(address(cd).balance, 1 ether);
vm.prank(address(wallet));
vm.expectRevert(bytes(""));
cd.refund();
assertEq(address(cd).balance, 1 ether);
}
}
Result
forge test --match-test test_ETHTransferGasLimit -vvvv
[⠆] Compiling...
No files changed, compilation skipped
Ran 1 test for test/ChristmasDinnerETHTest.t.sol:ChristmasDinnerETHTest
[PASS] test_ETHTransferGasLimit() (gas: 97339)
Traces:
[97339] ChristmasDinnerETHTest::test_ETHTransferGasLimit()
├─ [0] VM::deal(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 2000000000000000000 [2e18])
│ └─ ← [Return]
├─ [0] VM::prank(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ └─ ← [Return]
├─ [24234] ChristmasDinner::receive{value: 1000000000000000000}()
│ ├─ emit NewSignup(: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], : 1000000000000000000 [1e18], : true)
│ └─ ← [Stop]
├─ [0] VM::assertTrue(true, "Deposit should succeed") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(1000000000000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
├─ [0] VM::prank(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xf28dceb3: )
│ └─ ← [Return]
├─ [52327] ChristmasDinner::refund()
│ ├─ [7310] ERC20Mock::transfer(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0)
│ │ ├─ emit Transfer(from: ChristmasDinner: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], value: 0)
│ │ └─ ← [Return] true
│ ├─ [7310] ERC20Mock::transfer(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0)
│ │ ├─ emit Transfer(from: ChristmasDinner: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], value: 0)
│ │ └─ ← [Return] true
│ ├─ [7310] ERC20Mock::transfer(GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0)
│ │ ├─ emit Transfer(from: ChristmasDinner: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: GasHungryWallet: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], value: 0)
│ │ └─ ← [Return] true
│ ├─ [2258] GasHungryWallet::receive{value: 1000000000000000000}()
│ │ └─ ← [OutOfGas] EvmError: OutOfGas
│ └─ ← [Revert] EvmError: Revert
├─ [0] VM::assertEq(1000000000000000000 [1e18], 1000000000000000000 [1e18]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 312.73µs (74.81µs CPU time)
Ran 1 test suite in 611.28ms (312.73µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Contract receives 1 ETH deposit from wallet
Refund attempt triggers OutOfGas error in wallet's receive()
ETH remains stuck in contract (balance stays 1 ETH)
Impact
ETH permanently locked in contract
Smart contract wallets unable to receive refunds
No fallback mechanism to recover stuck funds
Tools Used
Manual review
Foundry test framework
Recommendations
function _refundETH(address payable _to) internal {
uint256 refundValue = etherBalance[_to];
(bool success,) = _to.call{value: refundValue}("");
require(success, "ETH transfer failed");
etherBalance[_to] = 0;
}