Pending Like ETH Becomes Permanently Locked When a Liked Profile Is Burned or Blocked
Description
Users can pay 1 ETH to like another valid profile. When the like is not yet mutual, the ETH remains pending in LikeRegistry until the other user reciprocates and the protocol creates a match.
The issue is that SoulboundProfileNFT allows a profile to be burned by the user or blocked by the owner without notifying LikeRegistry. Once the liked profile is removed, the pending like can no longer be completed because LikeRegistry.likeUser() requires both users to have active profile NFTs. The original 1 ETH remains stuck in LikeRegistry, is not refunded, and is not withdrawable as fees.
// LikeRegistry.sol
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");
}
// SoulboundProfileNFT.sol
function burnProfile() external {
uint256 tokenId = profileToToken[msg.sender];
require(tokenId != 0, "No profile found");
}
function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
}
Risk
Likelihood: Medium
This occurs whenever a user pays to like a profile and that liked profile is burned or blocked before reciprocating.
Profile deletion and owner blocking are normal protocol lifecycle actions, so this does not require privileged misuse beyond the intended blockProfile() flow or a user choosing to burn their own profile.
Impact: Medium
The liker’s pending 1 ETH becomes permanently locked in LikeRegistry.
The ETH is not sent to a match multisig, not refunded to the liker, and not withdrawable through withdrawFees() because totalFees was never increased.
Proof of Concept
The following test shows Alice paying 1 ETH to like Bob. Bob then burns his profile before reciprocating. Because Bob no longer has a profile NFT, he cannot complete the mutual match, and Alice’s ETH remains stuck in LikeRegistry.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/LikeRegistry.sol";
contract BurnBlockLocksPendingLikeTest is Test {
SoulboundProfileNFT nft;
LikeRegistry registry;
}
Recommended Mitigation
Before a profile is burned or blocked, clear all pending likes involving that profile and refund the affected users. One approach is to make SoulboundProfileNFT notify LikeRegistry before deleting a profile.
diff --git a/contracts/SoulboundProfileNFT.sol b/contracts/SoulboundProfileNFT.sol
index abc1234..def5678 100644
--- a/contracts/SoulboundProfileNFT.sol
+++ b/contracts/SoulboundProfileNFT.sol
@@
+interface ILikeRegistry {
function cancelPendingLikes(address user) external;
+}
contract SoulboundProfileNFT is ERC721, Ownable {
ILikeRegistry public likeRegistry;
function setLikeRegistry(address registry) external onlyOwner {
}
@@
function burnProfile() external {
uint256 tokenId = profileToToken[msg.sender];
require(tokenId != 0, "No profile found");
likeRegistry.cancelPendingLikes(msg.sender);
_burn(tokenId);
delete profileToToken[msg.sender];
delete _profiles[tokenId];
}
@@
function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
likeRegistry.cancelPendingLikes(blockAddress);
_burn(tokenId);
delete profileToToken[blockAddress];
delete _profiles[tokenId];
}
LikeRegistry.cancelPendingLikes() should refund pending like ETH for all unresolved likes involving the profile being removed, then clear the corresponding pending like state.
## 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.
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.