DatingDapp

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

No refund mechanism for unmatched likes — ETH permanently locked

Description

Every call to likeUser requires at least 1 ETH. If the liked user never reciprocates, the sender's ETH has no exit path. matchRewards is only called on a mutual match, so single-sided likes leave ETH stranded in the contract indefinitely. There is no cancelLike, refund, or user-facing withdraw function. withdrawFees is owner-only and restricted to totalFees — it cannot touch individual user payments:

function withdrawFees() external onlyOwner {
require(totalFees > 0, "No fees to withdraw"); // only the fee slice
uint256 totalFeesToWithdraw = totalFees;
totalFees = 0;
(bool success,) = payable(owner()).call{value: totalFeesToWithdraw}("");
require(success, "Transfer failed");
}

On a dating platform, the vast majority of likes will never be returned. Every unreturned like permanently destroys the sender's ETH.

Impact

Any user who likes someone who never likes back loses their full payment with zero recourse. This is the expected outcome for most interactions on the platform. Even the contract owner cannot recover individual user funds. Combined with H-02, the situation is compounded: matched ETH is also stuck, meaning there is no scenario under the current implementation where a user's ETH is returned or forwarded correctly.

PoC

Test: testPoC_UnmatchedLikeETHPermanentlyLocked in test/testLikeRegistry.t.sol

// PoC: ETH sent via likeUser is permanently locked when no mutual match occurs.
// There is no refund, cancel, or user-withdrawal function. withdrawFees only
// covers totalFees (the protocol fee slice); individual user ETH has no exit.
function testPoC_UnmatchedLikeETHPermanentlyLocked() public {
// Alice likes Bob — no mutual like from Bob
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
// 1 ETH is now in the contract
assertEq(address(likeRegistry).balance, 1 ether, "1 ETH locked in contract");
// There is no withdraw function for alice to reclaim her ETH
// withdrawFees only covers totalFees — which is 0 here (no match fired)
vm.prank(address(this)); // owner
vm.expectRevert("No fees to withdraw");
likeRegistry.withdrawFees();
// Bob never likes alice back — alice's 1 ETH is permanently irrecoverable
assertEq(address(likeRegistry).balance, 1 ether, "ETH still locked - no recovery path");
}
[PASS] testPoC_UnmatchedLikeETHPermanentlyLocked() (gas: 65,680)
Flow:
alice likeUser(bob) {value: 1 ETH} -> LikeRegistry.balance = 1 ETH
bob never likes back
withdrawFees() -> revert "No fees to withdraw"
LikeRegistry.balance = 1 ETH (permanently locked)

Run with: forge test --match-test testPoC_UnmatchedLikeETHPermanentlyLocked -vvv

Recommended Mitigation

Retain userBalances[msg.sender] += msg.value (from the H-02 fix) and expose a withdrawal function that allows users to reclaim their unmatched balance. Once a match fires, zero out the balance atomically so it cannot be double-withdrawn:

function withdrawBalance() external {
uint256 amount = userBalances[msg.sender];
require(amount > 0, "Nothing to withdraw");
userBalances[msg.sender] = 0;
(bool ok,) = payable(msg.sender).call{value: amount}("");
require(ok, "Transfer failed");
}
Updates

Lead Judging Commences

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