DatingDapp

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

No user-facing withdraw/refund: unmatched likers' ETH is permanently locked in LikeRegistry

Description

LikeRegistry.likeUser{value: 1 ether}(addr) holds the sent ETH in the contract. ETH only leaves via matchRewards (on a mutual match) or withdrawFees (onlyOwner, fees only). There is no user-callable withdraw/refund/cancelLike. So any ETH a user sends for a like that never becomes a mutual match is permanently locked. One-sided interest is the normal case for a dating app, so this is the dominant flow. (Distinct from the missing userBalances credit bug — even with correct accounting there is still no user exit.)

function withdrawFees() external onlyOwner { /* fees only */ }
// @> no user-callable function returns principal for an unmatched like

Risk

Likelihood: Medium — unmatched likes are expected behavior; every such like strands funds.
Impact: Medium — user principal (1 ETH per unmatched like) is irrecoverable.

Proof of Concept

The test below shows the trap: Alice likes Bob with 1 ETH, Bob never likes back, so the ETH sits in the registry. We then assert that Alice has no way to retrieve it — withdrawFees() reverts for her (it is onlyOwner), no other recovery function exists, and her balance stays down 1 ETH permanently. Add to a Foundry test file:

function test_UnmatchedLikeIsPermanentlyLocked() public {
vm.prank(alice); nft.mintProfile("alice", 25, "img");
vm.prank(bob); nft.mintProfile("bob", 26, "img");
vm.deal(alice, 5 ether);
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob); // Bob never likes back
assertEq(address(registry).balance, 1 ether);
assertEq(alice.balance, 4 ether);
// No withdraw/refund exists; withdrawFees is onlyOwner:
vm.prank(alice);
vm.expectRevert();
registry.withdrawFees();
assertEq(alice.balance, 4 ether); // funds never recovered
}

The two final assertions are the proof: Alice's balance never returns to 5 ETH and the registry still holds her 1 ETH, with no code path to release it to her.

Recommended Mitigation

Track refundable principal and add a user-callable withdraw; zero it on a match.

mapping(address => uint256) public refundable;
function likeUser(address liked) external payable {
require(msg.value >= 1 ether, "Must send at least 1 ETH");
likes[msg.sender][liked] = true;
refundable[msg.sender] += msg.value;
if (likes[liked][msg.sender]) matchRewards(liked, msg.sender);
}
function matchRewards(address from, address to) internal {
uint256 fromFunds = refundable[from];
uint256 toFunds = refundable[to];
refundable[from] = 0;
refundable[to] = 0;
// ... existing fee + MultiSig pooling using fromFunds/toFunds ...
}
function withdraw() external {
uint256 amount = refundable[msg.sender];
require(amount > 0, "Nothing to withdraw");
refundable[msg.sender] = 0; // CEI
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "Withdraw failed");
}

Why this fixes it: principal is recorded on every like; matched funds are zeroed and moved out by matchRewards, while unmatched funds stay reclaimable via withdraw() (CEI-ordered to prevent reentrancy). Unmatched likers now have a guaranteed exit the contract currently lacks.

Updates

Lead Judging Commences

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