DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: low
Invalid

Profile existence check relies on profileToToken != 0 sentinel — any change to token ID start value silently disables all profile validation

Root + Impact

Description

// SoulboundProfileNFT.sol — mintProfile()
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
// @> Uses profileToToken == 0 as "no profile" sentinel
require(profileToToken[msg.sender] == 0, "Profile already exists");
// @> Pre-increment ensures IDs start at 1 — but this is a silent invariant, not documented
// @> Changing to _nextTokenId++ would issue token ID 0 and break everything
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);
}
// LikeRegistry.sol — likeUser()
function likeUser(address liked) external payable {
// ...
// @> Both checks rely on the zero-sentinel — no explicit hasProfile mapping
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

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// Modified version showing what happens if _nextTokenId uses post-increment
contract BrokenSoulboundNFT {
mapping(address => uint256) public profileToToken;
uint256 private _nextTokenId; // starts at 0
function mintProfile(string memory name) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
// @> post-increment: first token ID issued is 0 — same as sentinel
uint256 tokenId = _nextTokenId++;
profileToToken[msg.sender] = tokenId; // == 0 for first user
}
}
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");
// Alice minted token ID 0
uint256 tokenId = nft.profileToToken(alice);
assertEq(tokenId, 0);
// LikeRegistry's check: profileToToken(alice) != 0 → FALSE
// Alice is rejected as having no profile despite having minted one
assertFalse(tokenId != 0);
}
function test_firstUserCanMintTwice() public {
vm.prank(alice);
nft.mintProfile("Alice"); // mints token 0, profileToToken[alice] = 0
// Guard reads profileToToken[alice] == 0 → "Profile already exists" does NOT trigger
vm.prank(alice);
nft.mintProfile("Alice2"); // mints AGAIN — Sybil protection broken
}
}

Recommended Mitigation

Replace the zero-sentinel pattern with an explicit boolean mapping that clearly documents and enforces the "has profile" invariant:

// SoulboundProfileNFT.sol
// @> Add explicit existence mapping — no reliance on token ID sentinel
mapping(address => bool) public hasProfile;
mapping(address => uint256) public profileToToken;
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
// @> Use explicit bool — immune to token ID numbering changes
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; // @> set explicit flag
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; // @> clear explicit flag
emit ProfileBurned(msg.sender, tokenId);
}
// LikeRegistry.sol — update checks to use hasProfile
require(profileNFT.hasProfile(msg.sender), "Must have a profile NFT");
require(profileNFT.hasProfile(liked), "Liked user must have a profile NFT");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!