DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

Every like-payment is permanently locked: `userBalances` is never credited

Root

L34 likeUser(liked) external payable
L35 require(msg.value >= 1 ether) // payment taken
L41 likes[msg.sender][liked] = true
// ... NO userBalances[msg.sender] += msg.value <-- missing credit
L46 if (likes[liked][msg.sender]) // mutual match
L50 matchRewards(liked, msg.sender)
L55 matchRewards(from, to) internal
L56 matchUserOne = userBalances[from] // always 0
L57 matchUserTwo = userBalances[to] // always 0
L62 totalRewards = matchUserOne + matchUserTwo // 0
L63 matchingFees = 0 · FIXEDFEE / 100 // 0
L64 rewards = 0 // 0
L65 totalFees += 0 // 0, forever
L74 call{value: rewards}("") // sends 0 ETH
L83 withdrawFees() onlyOwner
L84 require(totalFees > 0) // reverts: totalFees == 0

Impact

  • Every likeUser permanently removes >= 1 ETH from circulation into a contract
    that has exactly one outgoing function (withdrawFees), which can only send
    totalFees

  • Users can never recover their like payments; the promised match pool are pooled into a shared
    multisig wallet is never funded, so matched users receive 0.

  • Even the owner cannot sweep the balance

  • Any matching user loses at least 1 ETH; the loss scales with
    the number of likes

Description

likeUser accepts >= 1 ETH per like but never writes it into userBalances.
The only two write sites for userBalances in the entire codebase zero it out
(matchRewards, L58–59). Because balances stay 0, every mutual match pays out
0 ETH, totalFees stays 0, and withdrawFees can never move the contract's
ETH. All like-payments are permanently stranded in LikeRegistry with no recovery
path for any actor

Risk

Can lead to loss of Funds

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/LikeRegistry.sol";
import "../src/MultiSig.sol";
/// @notice Proof-of-concept: every like-payment is permanently locked.
/// `likeUser` requires >=1 ETH but never credits `userBalances`, so
/// match rewards are always 0 and no actor (users or owner) can recover
/// the ETH held by LikeRegistry.
contract PocLikeRegistryLock is Test {
SoulboundProfileNFT nft;
LikeRegistry registry;
address alice;
address bob;
address attacker;
address owner;
function setUp() public {
owner = address(0xA11CE); // deployer + owner
nft = new SoulboundProfileNFT();
registry = new LikeRegistry(address(nft));
alice = makeAddr("alice");
bob = makeAddr("bob");
attacker = makeAddr("attacker");
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://a");
vm.prank(bob);
nft.mintProfile("Bob", 26, "ipfs://b");
vm.prank(attacker);
nft.mintProfile("Attacker", 21, "ipfs://c");
}
function _totalFees() internal view returns (uint256) {
// LikeRegistry storage: slot 0 = _owner, slot 1 = profileNFT, slot 2 = totalFees
return uint256(vm.load(address(registry), bytes32(uint256(2))));
}
function test_likeFees_areNeverCredited_andPermanentlyStuck() public {
// alice likes bob, paying the required >=1 ETH
vm.deal(alice, 100 ether);
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
// ETH is taken but never credited
assertEq(address(registry).balance, 1 ether, "ETH landed in registry");
assertEq(registry.userBalances(alice), 0, "alice credited 0");
assertEq(_totalFees(), 0, "no fee accrued");
// bob likes alice -> mutual match -> matchRewards computes from 0 balances
vm.deal(bob, 100 ether);
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
assertEq(registry.matches(alice, 0), bob, "match recorded");
assertEq(registry.userBalances(alice), 0, "alice balance still 0 after match");
assertEq(registry.userBalances(bob), 0, "bob balance still 0 after match");
assertEq(_totalFees(), 0, "no fees from a 0-reward match");
// Both payments (2 ETH) remain stranded in the registry.
assertEq(address(registry).balance, 2 ether, "2 ETH stuck in registry");
// Owner cannot rescue: withdrawFees only moves totalFees (== 0).
// The test contract is the deployer/owner; a legitimate call still reverts.
vm.expectRevert("No fees to withdraw");
registry.withdrawFees();
// No other path moves ETH out: balance is unrecoverable by anyone.
assertEq(address(registry).balance, 2 ether, "still locked");
}
function test_overpay_isAlsoLocked() public {
// A user who overpays (>=1) loses every wei over the minimum too.
vm.deal(attacker, 100 ether);
vm.prank(attacker);
registry.likeUser{value: 7 ether}(bob);
assertEq(address(registry).balance, 7 ether);
assertEq(registry.userBalances(attacker), 0);
}

}
PoC evidence: after two mutual likes of 1 ETH each, LikeRegistry holds 2 ETH,
both userBalances are 0, totalFees is 0, the matched pair received 0, and
a legitimate withdrawFees() by the owner reverts with "No fees to withdraw".

Recommended Mitigation

Credit the payment atomically when it is taken, following CEI:

likes[msg.sender][liked] = true;
userBalances[msg.sender] += msg.value; // credit BEFORE the match-branch

and in matchRewards, move the external call to the end (the multisig receive()
is empty today, so reentrancy risk is nil, but ordering should be hardened anyway).

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-01] After the user calls the `likeUser` function, the userBalance does not increase by the corresponding value.

## 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); [...] } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!