Root + Impact
Description
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId);
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
function likeUser(address liked) external payable {
require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT");
}
Risk
Likelihood:
The current code is safe in isolation. This finding becomes an active exploit during any contract upgrade, fork, or copy where a developer changes ++_nextTokenId to _nextTokenId++ (a common mistake), or initialises _nextTokenId = 0 explicitly and uses post-increment.
Contest judges value fragile-invariant findings because they highlight latent bugs that manifest silently under refactoring — a common source of production vulnerabilities.
Impact:
If token ID 0 is issued, profileToToken[firstUser] == 0 — the LikeRegistry permanently rejects this user despite having a valid minted NFT.
The same user can call mintProfile() again (since the guard also reads 0 as "no profile"), minting a second token and gaining duplicate profile state.
The protocol's one-profile-per-address Sybil-resistance guarantee is silently destroyed without any revert or error.
Proof of Concept
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
contract BrokenSoulboundNFT {
mapping(address => uint256) public profileToToken;
uint256 private _nextTokenId;
function mintProfile(string memory name) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = _nextTokenId++;
profileToToken[msg.sender] = tokenId;
}
}
contract SentinelBugTest is Test {
BrokenSoulboundNFT nft;
address alice = makeAddr("alice");
function setUp() public {
nft = new BrokenSoulboundNFT();
}
function test_firstUserProfileInvisibleToRegistry() public {
vm.prank(alice);
nft.mintProfile("Alice");
uint256 tokenId = nft.profileToToken(alice);
assertEq(tokenId, 0);
assertFalse(tokenId != 0);
}
function test_firstUserCanMintTwice() public {
vm.prank(alice);
nft.mintProfile("Alice");
vm.prank(alice);
nft.mintProfile("Alice2");
}
}
Recommended Mitigation
Replace the zero-sentinel pattern with an explicit boolean mapping that clearly documents and enforces the "has profile" invariant:
mapping(address => bool) public hasProfile;
mapping(address => uint256) public profileToToken;
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(!hasProfile[msg.sender], "Profile already exists");
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId);
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
hasProfile[msg.sender] = true;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
function burnProfile() external {
require(hasProfile[msg.sender], "No profile found");
uint256 tokenId = profileToToken[msg.sender];
_burn(tokenId);
delete profileToToken[msg.sender];
delete _profiles[tokenId];
hasProfile[msg.sender] = false;
emit ProfileBurned(msg.sender, tokenId);
}
require(profileNFT.hasProfile(msg.sender), "Must have a profile NFT");
require(profileNFT.hasProfile(liked), "Liked user must have a profile NFT");