DatingDapp

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

LikeRegistry.receive() silently accepts arbitrary ETH that is not credited to any balance and cannot be withdrawn by the owner or users

Root + Impact

Normal behavior

LikeRegistry holds user ETH via likeUser() calls, which (once the balance-credit bug is fixed) write incoming ETH to userBalances[msg.sender]. The owner can withdraw accumulated protocol fees via withdrawFees(), which draws from totalFees. The contract balance should at all times equal the sum of all userBalances plus totalFees.

Description

The issue

The contract declares a bare receive() fallback:

receive() external payable {}

This allows any address to send arbitrary ETH directly to LikeRegistry without triggering any accounting. ETH sent this way is not written to userBalances or totalFees. The withdrawFees() function only withdraws from totalFees — it has no visibility into the untracked balance surplus. There is no rescueETH(), no sweep(), and no other mechanism to recover these funds.

The result is a permanently growing gap between address(this).balance and the sum of all accounted balances — ETH that sits in the contract and is inaccessible to anyone.

Root cause in the codebase

// LikeRegistry.sol
// @> Bare receive() — accepts any ETH with zero accounting
receive() external payable {}
// @> withdrawFees only touches totalFees — untracked ETH is unreachable
function withdrawFees() external onlyOwner {
require(totalFees > 0, "No fees to withdraw");
uint256 totalFeesToWithdraw = totalFees;
totalFees = 0;
(bool success,) = payable(owner()).call{value: totalFeesToWithdraw}("");
require(success, "Transfer failed");
}
// @> No function reads address(this).balance - totalFees - sum(userBalances)
// @> No rescueETH() or sweep() function exists

Risk

Likelihood:

  • Direct ETH sends to contract addresses happen regularly: users testing the contract, wallet software that sends ETH to the last interacted address, accidental transfers from exchanges, and protocol integrations that push ETH before calling a function.

  • The bare receive() means all of these silently succeed instead of reverting, providing no feedback that the ETH is lost.

Impact:

  • Any ETH sent via receive() is permanently locked with no recovery mechanism for either the sender or the contract owner.

  • The discrepancy between address(this).balance and accounted balances can confuse off-chain monitoring tools and accounting dashboards, masking the actual health of the protocol's ETH reserves.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/LikeRegistry.sol";
import "../src/SoulboundProfileNFT.sol";
contract UntrackedEthTest is Test {
LikeRegistry registry;
SoulboundProfileNFT nft;
address sender = makeAddr("sender");
function setUp() public {
nft = new SoulboundProfileNFT();
registry = new LikeRegistry(address(nft));
deal(sender, 5 ether);
}
function test_directSendLocked() public {
// Send ETH directly via receive()
vm.prank(sender);
(bool ok,) = address(registry).call{value: 1 ether}("");
assertTrue(ok, "receive() accepted ETH");
// ETH is in the contract
assertEq(address(registry).balance, 1 ether);
// But totalFees is 0 — withdrawFees() cannot touch it
// No other withdrawal path exists
vm.expectRevert("No fees to withdraw");
registry.withdrawFees();
// ETH permanently stuck
assertEq(address(registry).balance, 1 ether);
}
}

Recommended Mitigation

Either remove the bare receive() (to revert accidental sends) or add a rescueETH() owner function that recovers the unaccounted surplus:

Option A — Remove receive() (preferred if direct ETH sends are not a use case):

// @> Simply remove the receive() function
// Any direct ETH send will revert with a clear error

Option B — Add rescueETH() to recover untracked surplus:

// LikeRegistry.sol
/// @notice Allows owner to recover ETH sent directly to the contract (not via likeUser)
function rescueETH() external onlyOwner {
// @> Calculate untracked surplus: total balance minus all accounted ETH
// Note: requires tracking totalUserBalances as a state variable
uint256 accounted = totalFees + totalUserBalances; // totalUserBalances = sum of userBalances
uint256 surplus = address(this).balance - accounted;
require(surplus > 0, "No surplus to rescue");
(bool success,) = payable(owner()).call{value: surplus}("");
require(success, "Rescue failed");
}

For Option B, totalUserBalances must be tracked as a running sum incremented in likeUser() and decremented in matchRewards().

Updates

Lead Judging Commences

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