likeUser accepts at least 1 ETH and records the like, but never writes userBalances or totalFees. The native ETH remains in the registry with no outbound path: one-way likes sit unaccounted, mutual matches pay a 0 ETH date pot, and the owner cannot withdraw fees.
Every like principal is permanently unrecoverable. After a one-way like the registry holds the ETH while userBalances and totalFees stay 0. A later mutual match enters matchRewards but snapshots those zero balances, deploys a MultiSig, and transfers 0. The owner’s withdrawFees always reverts with No fees to withdraw. There is no user refund and no owner sweep. Any profiled user can lock 1 ETH (or more) per like; the loss is protocol-wide and grows with every like.
The intended accounting path is: credit each like deposit to userBalances[liker], then on a mutual like snapshot both balances, take a 10% fee into totalFees, and send the remaining 90% to a newly deployed MultiSig date pot.
likeUser never performs that credit. It is payable, requires msg.value >= 1 ether, writes likes[msg.sender][liked] = true, emits Liked, and returns unless the reverse like already exists. There is no userBalances[msg.sender] += msg.value (or any equivalent) in the function body, in a modifier, or in receive().
The only write to userBalances is the snapshot-then-zero inside matchRewards. totalFees is incremented only from that snapshot:
Because the ledger was never incremented, matchUserOne and matchUserTwo are always 0. The MultiSig is still deployed and Matched is still emitted, but rewards == 0 and matchingFees == 0.
withdrawFees is the only drain and is gated on totalFees > 0:
receive() accepts additional ETH and also does not create an accounting entry. The solvency invariant address(this).balance == sum(userBalances) + totalFees is therefore broken on every like: native balance increases by msg.value while accounted claims stay 0. There is no residual path that can move that surplus to users or to the owner.
Foundry suite test/LikeUserLockedFunds.t.sol executed with forge test --match-path test/LikeUserLockedFunds.t.sol -vvvv. Result: 3 passed, 0 failed.
One-way like (matchRewards not entered):
INV-001 broken: 1000000000000000000 != 0.
Mutual match (matchRewards entered, still cannot recover):
A later one-way like of a third user after the match locks a third ETH the same way (balance == 3 ether, balances still 0, withdrawFees still reverts).
Deploy SoulboundProfileNFT and LikeRegistry, using the NFT address as the registry constructor argument.
Have Alice, Bob, and Carol each call public mintProfile (no privileged role required).
Fund Alice with at least 1 ETH and call likeUser(Bob) with value = 1 ether.
Observe that Liked(Alice, Bob) is emitted and likes[Alice][Bob] is true, but Matched is not emitted and matchRewards is not entered.
Read userBalances(Alice) and userBalances(Bob) — both are 0. The registry native balance is 1 ETH.
As the owner, call withdrawFees and observe the revert No fees to withdraw.
Have Bob call likeUser(Alice) with value = 1 ether. Observe Liked and Matched, and a new MultiSig deployment.
Confirm both user balances remain 0, the registry native balance is now 2 ETH, neither user received a date-pot payout, and withdrawFees still reverts.
Have Alice call likeUser(Carol) with value = 1 ether. The registry native balance becomes 3 ETH with all balances and fees still 0.
Location 1: src/LikeRegistry.sol (lines 31-48)
Credit like deposits to userBalances before match branch
Suggested Fix:
Location 2: src/LikeRegistry.sol (lines 50-67)
matchRewards is the only writer of userBalances and only increments totalFees from that snapshot
Location 3: src/LikeRegistry.sol (lines 73-80)
withdrawFees is the only drain and reverts when totalFees is zero
Credit each like deposit to the payer’s ledger inside likeUser immediately after the like is recorded and before the mutual-like branch. Do not credit only inside matchRewards: one-way likes would still lock ETH, and earlier likes of a newly matched user would still be missing from the date pot. Leave the existing snapshot-then-zero logic in matchRewards unchanged once balances are actually funded; that path then takes the 10% fee and forwards the 90% pot as designed. Optionally add a solvency invariant that the registry native balance equals the sum of all userBalances plus totalFees (excluding in-flight transfers). Do not rely on receive() or an owner sweep as a substitute for correct deposit accounting.
Assumes an attacker can call the permissionless public mintProfile and then likeUser with at least 1 ETH. Both preconditions are satisfied in the executed Foundry tests. No privileged role, owner key, or second vulnerability is required.
## Description User A calls `likeUser` and sends `value > 1` ETH. According to the design of DatingDapp, the amount for user A should be accumulated by `userBalances`. Otherwise, in the subsequent calculations, the balance for each user will be 0. ## Vulnerability Details When User A calls `likeUser`, the accumulation of `userBalances` is not performed. ```solidity function likeUser( address liked ) external payable { require(msg.value >= 1 ether, "Must send at least 1 ETH"); require(!likes[msg.sender][liked], "Already liked"); require(msg.sender != liked, "Cannot like yourself"); require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT"); require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT"); likes[msg.sender][liked] = true; emit Liked(msg.sender, liked); // Check if mutual like if (likes[liked][msg.sender]) { matches[msg.sender].push(liked); matches[liked].push(msg.sender); emit Matched(msg.sender, liked); matchRewards(liked, msg.sender); } } ``` This will result in `totalRewards` always being 0, affecting all subsequent calculations: ```solidity uint256 totalRewards = matchUserOne + matchUserTwo; uint256 matchingFees = (totalRewards * FIXEDFEE ) / 100; uint256 rewards = totalRewards - matchingFees; totalFees += matchingFees; ``` ## POC ```solidity function testUserBalanceshouldIncreaseAfterLike() public { vm.prank(user1); likeRegistry.likeUser{value: 20 ether}(user2); assertEq(likeRegistry.userBalances(user1), 20 ether, "User1 balance should be 20 ether"); } ``` Then we will get an error: ```shell [FAIL: User1 balance should be 20 ether: 0 != 20000000000000000000] ``` ## Impact - Users will be unable to receive rewards. - The contract owner will also be unable to withdraw ETH from the contract. ## Recommendations Add processing for `userBalances` in the `likeUser` function: ```diff function likeUser( address liked ) external payable { require(msg.value >= 1 ether, "Must send at least 1 ETH"); require(!likes[msg.sender][liked], "Already liked"); require(msg.sender != liked, "Cannot like yourself"); require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT"); require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT"); likes[msg.sender][liked] = true; + userBalances[msg.sender] += msg.value; emit Liked(msg.sender, liked); [...] } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.