DatingDapp

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

User Funds Can Become Permanently Locked When a Liked Profile Is Burned or Blocked

[M-2] User Funds Can Become Permanently Locked When a Liked Profile Is Burned or Blocked

Note: Please resolve [H-1] before addressing [M-2], as [M-2] depends on correct per-like fund tracking introduced in the [H-1] mitigation.

Description

When a user calls likeUser, they are required to send at least MIN_LIKE_AMOUNT ETH, which is stored in the likes[msg.sender][liked].totalSent field and held by the contract until a mutual like occurs and matchRewards is executed.

However, there is no mechanism to refund or reclaim these funds if the liked user’s profile is removed before a match is formed. A profile can be removed in two ways:

The liked user voluntarily calls burnProfile.

The contract owner forcibly removes the profile using blockProfile.

In both cases, the liked user’s profile NFT is burned and their profile data is deleted, making it impossible for them to ever like back and complete a match. Despite this, the ETH previously sent by the liking user remains locked in the contract, with no function available to withdraw or refund it.

As a result, any user who likes a profile that is later burned or blocked permanently loses access to their deposited ETH, even though no match can ever be formed and no rewards will ever be distributed.

Risk:

Impact: Medium

  • User funds sent during likeUser can become permanently locked inside the contract.

  • Users have no way to recover their deposited ETH if the counterparty profile is burned or blocked before a match is formed.

  • Funds are lost without any fault from the sending user, even though no rewards are ever distributed.

=> This results in direct financial loss and degrades user trust in the protocol.

Likelihood: Medium

  • This issue occurs whenever a liked profile is removed before forming a match.

  • Any user who likes a profile that is later burned by the user or blocked by the owner will be affected.

  • Profile burning and admin blocking are expected lifecycle operations, not exceptional edge cases.

  • No malicious behavior is required—normal contract usage is sufficient to trigger the issue.

Proof of Concept:

  • Copy the code below to testSoulboundProfileNFT.t.sol.

  • Run command forge test --mt testLikedProfilesThenBurn -vvvv.

function testLikedProfilesThenBurn() public {
//Deal some ether to users
vm.deal(user, 1 ether);
vm.deal(user2, 1 ether);
// Mint profiles for both users
vm.prank(user);
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
vm.prank(user2);
soulboundNFT.mintProfile("Bob", 30, "ipfs://profileImageBob");
// User2 likes User1
vm.prank(user2);
likeRegistry.likeUser{value: 1 ether}(user);
// Check LikeRegistry balance before burning
uint256 LikeRegistryBalanceBefore = address(likeRegistry).balance;
assertEq(LikeRegistryBalanceBefore, 1 ether);
// User1 burns their profile
vm.prank(user);
soulboundNFT.burnProfile();
uint256 tokenIdAfterBurn = soulboundNFT.profileToToken(user);
assertEq(tokenIdAfterBurn, 0, "Token should be removed after burning");
// Check the LikeRegistry balance after user 1 burns the profile. The contract balance remains 1 ether even after user1 burns the profile after receiving a like from user2.
uint256 LikeRegistryBalanceAfter = address(likeRegistry).balance;
assertEq(LikeRegistryBalanceAfter, 1 ether);
// User2's like for User1 still exists after user1 burns profile
(uint256 totalSent, LikeRegistry.LikeStatus status) = likeRegistry.likes(user2, user);
assertEq(uint256(status), uint256(LikeRegistry.LikeStatus.Liked));
}

Recommended Mitigation:

  • Put this following mitigation in conntract LikeRegistry:

    • Add mapping(address => address[]) public likedUsers; to track all users that each address has liked.

    • Add uint256 public constant MAX_LIKEDUSER_AMOUNT = 100; to cap the number of liked users per account and prevent gas limit issues

    • Add a refundUnmatchedLikesFromBurnedProfiles function to allow users to reclaim funds sent to profiles that have been burned or blocked before a match is formed.

+ mapping(address => address[]) public likedUsers;
+ uint256 public constant MAX_LIKEDUSER_AMOUNT = 100;
function likeUser(address liked) external payable {
require(msg.value >= MIN_LIKE_AMOUNT, "Must send at least 1 ETH"); //audit-change
require(likes[msg.sender][liked].status == LikeStatus.None, "Already liked or matched"); //audit-change to check for enum status
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");
+ require(likedUsers[msg.sender].length < MAX_LIKEDUSER_AMOUNT, "Exceeded maximum liked users");
+ likedUsers[msg.sender].push(liked);
Like storage l = likes[msg.sender][liked]; //audit-change
l.status = LikeStatus.Liked; //audit-change
l.totalSent = msg.value; //audit-change: keep track of the amount sent to liked user
emit Liked(msg.sender, liked);
// Check if mutual like
if (likes[liked][msg.sender].status == LikeStatus.Liked) {
likes[msg.sender][liked].status = LikeStatus.Matched; //audit-add
likes[liked][msg.sender].status = LikeStatus.Matched; //audit-add
matchRewards(liked, msg.sender);
}
}
+ function refundUnmatchedLikesFromBurnedProfiles() external {
+ require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
+ address[] storage targets = likedUsers[msg.sender];
+ uint256 totalRefund = 0;
+
+ for (uint256 i = 0; i < targets.length; i++) {
+ address liked = targets[i];
+ Like storage l = likes[msg.sender][liked];
+
+ if (
+ l.status == LikeStatus.Liked &&
+ l.totalSent > 0 &&
+ profileNFT.profileToToken(liked) == 0
+ ) {
+ totalRefund += l.totalSent;
+
+ l.totalSent = 0;
+ l.status = LikeStatus.None;
+ }
+ }
+
+ require(totalRefund > 0, "Nothing to refund");
+
+ (bool success,) = payable(msg.sender).call{value: totalRefund}("");
+ require(success, "Refund failed");
+ }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 15 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] App owner can have users' funds locked by blocking them

## Description App owner can block users at will, causing users to have their funds locked. ## Vulnerability Details `SoulboundProfileNFT::blockProfile` can block any app's user at will. ```js /// @notice App owner can block users function blockProfile(address blockAddress) external onlyOwner { uint256 tokenId = profileToToken[blockAddress]; require(tokenId != 0, "No profile found"); _burn(tokenId); delete profileToToken[blockAddress]; delete _profiles[tokenId]; emit ProfileBurned(blockAddress, tokenId); } ``` ## Proof of Concept The following code demonstrates the scenario where the app owner blocks `bob` and he is no longer able to call `LikeRegistry::likeUser`. Since the contract gives no posibility of fund withdrawal, `bob`'s funds are now locked. Place `test_blockProfileAbuseCanCauseFundLoss` in `testSoulboundProfileNFT.t.sol`: ```js function test_blockProfileAbuseCanCauseFundLoss() public { vm.deal(bob, 10 ether); vm.deal(alice, 10 ether); // mint a profile NFT for bob vm.prank(bob); soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage"); // mint a profile NFT for Alice vm.prank(alice); soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); // alice <3 bob vm.prank(alice); likeRegistry.likeUser{value: 1 ether}(bob); vm.startPrank(owner); soulboundNFT.blockProfile(bob); assertEq(soulboundNFT.profileToToken(msg.sender), 0); vm.startPrank(bob); vm.expectRevert("Must have a profile NFT"); // bob is no longer able to like a user, as his profile NFT is deleted // his funds are effectively locked likeRegistry.likeUser{value: 1 ether}(alice); } ``` And run the test: ```bash $ forge test --mt test_blockProfileAbuseCanCauseFundLoss Ran 1 test for test/testSoulboundProfileNFT.t.sol:SoulboundProfileNFTTest [PASS] test_blockProfileAbuseCanCauseFundLoss() (gas: 326392) Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.42ms (219.63µs CPU time) Ran 1 test suite in 140.90ms (1.42ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` ## Impact App users can have their funds locked, as well as miss out on potential dates. ## Recommendations Add a voting mechanism to prevent abuse and/or centralization of the feature.

Support

FAQs

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

Give us feedback!