Severity: Medium
The app owner can call SoulboundProfileNFT::blockProfile() on any user at any time. If that user has already paid ETH into LikeRegistry through likeUser() but has not yet completed a mutual match, their funds can become permanently stuck.
This happens because blocking removes the user’s profile NFT, and LikeRegistry requires both parties to have an active profile NFT before a like can be made. Once blocked, the user can no longer complete future matches, and there is no refund or withdrawal path for their pending balance.
SoulboundProfileNFT::blockProfile() lets the owner forcibly remove any user profile:
Meanwhile, LikeRegistry::likeUser() requires both the caller and the liked user to hold profile NFTs:
If a user already deposited ETH through likeUser() and is then blocked by the owner:
their profile is removed
they can no longer like anyone again
others can no longer like them
any pending deposits associated with them can no longer progress toward a match
there is no withdrawal or refund function for those pending deposits
So the owner has the power to freeze user participation in a way that can strand deposited ETH inside LikeRegistry.
The owner can arbitrarily cause users’ pending funds to become inaccessible.
This breaks an important fairness assumption of the protocol:
users may deposit ETH in good faith
the owner can unilaterally prevent those deposits from ever being used for a match
there is no recovery path for the affected user
This is especially relevant because the project’s core flow is built around pre-paying for a future mutual match. If admin moderation can permanently disable a user without refunding their pending balance, users face custodial loss risk.
The protocol couples profile existence to match eligibility, but does not provide any mechanism to unwind or refund pending user funds when an admin removes a profile.
LikeRegistry holds user-deposited ETH for unmatched likes
there is no refund or cancel function for pending deposits
owner has permission to call blockProfile()
a user has deposited ETH through likeUser()
the user is blocked before those funds are consumed in a match
Alice mints a profile NFT.
Bob mints a profile NFT.
Alice calls likeUser(Bob) and deposits ETH.
Before Bob reciprocates, the owner calls blockProfile(Alice).
Alice’s profile NFT is burned.
Alice can no longer like others.
Bob can no longer call likeUser(Alice) because Alice no longer has a valid profile NFT.
Alice’s pending deposit remains stuck in LikeRegistry with no refund path.
This PoC assumes likeUser() correctly credits userBalances[msg.sender] += msg.value, since otherwise H-01 masks the issue.
This test shows that:
Alice successfully deposits ETH into the system
the owner removes Alice’s profile
Bob can no longer reciprocate the like
Alice’s recorded funds remain in LikeRegistry
there is no mechanism for Alice to recover them
So the owner can lock pending user funds by blocking the user.
There are a few reasonable fixes, depending on intended protocol behavior:
Option 1: Refund on block
When a user is blocked, add logic in LikeRegistry to return their unmatched balance.
Option 2: Add user withdrawal / cancel path
Allow users to withdraw unmatched deposits if they are blocked or if a match never occurs.
Option 3: Separate moderation from funds custody
Do not let blocking immediately destroy the ability to settle or recover pending balances. For example, mark a profile as blocked for future participation, but preserve a way to unwind open financial state.
Option 4: Pair-specific deposit accounting
Track deposits per pair and allow forced settlement/refund when one side is blocked.
A minimal mitigation direction would be:
add a blocked-user status instead of directly destroying financial reachability
add a refund function for unmatched balances
ensure admin moderation cannot strand user funds
## 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.