DatingDapp

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

After the user calls the `likeUser` function, the userBalance does not increase by the corresponding value.

H-01: userBalance Not Updated on likeUser Call

Root + Impact

Description

  • The likeUser function is designed to accumulate ETH payments into user balances, which are later used to calculate match rewards and distribute funds to shared multisig wallets.

  • However, the function does not update the userBalances mapping when a user sends ETH, causing all user balances to remain at zero.

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");
likes[msg.sender][liked] = true;
// @> Missing: userBalances[msg.sender] += msg.value;
emit Liked(msg.sender, liked);
// Check if mutual like
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}

Risk

Likelihood:

  • Occurs every time a user calls likeUser with ETH payment

  • The balance is never accumulated regardless of the number of likes or amounts sent

Impact:

  • Users cannot receive rewards from their like payments

  • Matched users receive multisig wallets with zero balance

  • Contract owner cannot withdraw platform fees as totalFees remains zero

Proof of Concept

function testUserBalanceshouldIncreaseAfterLike() public {
vm.prank(user1);
likeRegistry.likeUser{value: 20 ether}(user2);
assertEq(likeRegistry.userBalances(user1), 20 ether, "User1 balance should be 20 ether");
// FAILS: User1 balance should be 20 ether: 0 != 20000000000000000000
}

Recommended Mitigation

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");
likes[msg.sender][liked] = true;
+ userBalances[msg.sender] += msg.value;
emit Liked(msg.sender, liked);
// Check if mutual like
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}

M-01: Blocked Profiles Can Be Recreated

Root + Impact

Description

  • The blockProfile function permanently blocks a user by burning their NFT and removing them from the system, intended to prevent re-entry.

  • However, the function uses delete on the profileToToken mapping, which resets the value to zero. The mintProfile function checks require(profileToToken[msg.sender] == 0) to prevent duplicate profiles, which means a blocked user can bypass this check by reminting a new profile.

function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
_burn(tokenId);
delete profileToToken[blockAddress];
// @> Deleting the mapping allows profileToToken[blockAddress] to return 0
delete _profiles[tokenId];
emit ProfileBurned(blockAddress, tokenId);
}
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
// @> This check can be bypassed if profileToToken was deleted
require(profileToToken[msg.sender] == 0, "Profile already exists");
// ... rest of function
}

Risk

Likelihood:

  • Occurs every time a blocked account attempts to mint a new profile

  • The deleted mapping entry provides no permanent block record

Impact:

  • Blocked users can circumvent platform restrictions and regain access

  • Platform integrity is compromised as malicious users can evade permanent bans

  • Previous violations by blocked users are not enforced

Proof of Concept

function testRecereationOfBlockedAccount() public {
// Alice mints a profile successfully
vm.prank(user);
soulboundNFT.mintProfile("Alice", 18, "ipfs://profileImageAlice");
// Owner blocks Alice's account, which deletes Alice profile mapping
vm.prank(owner);
soulboundNFT.blockProfile(user);
// The blocked user (Alice) attempts to mint a new profile.
// Due to the reset mapping value (0), the require check is bypassed.
vm.prank(user);
soulboundNFT.mintProfile("Alice", 18, "ipfs://profileImageAlice");
// PASSES: User successfully recreated profile
}

Recommended Mitigation

+ mapping(address => bool) public isBlocked;
// ... in mintProfile function
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
+ require(!isBlocked[msg.sender], "Account is permanently blocked");
require(profileToToken[msg.sender] == 0, "Profile already exists");
// ... rest of function
}
// ... in blockProfile function
function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
_burn(tokenId);
delete profileToToken[blockAddress];
delete _profiles[tokenId];
+ isBlocked[blockAddress] = true;
emit ProfileBurned(blockAddress, tokenId);
}

M-02: Logic Flaw in matchRewards Allows Free Dates

Root + Impact

Description

  • The matchRewards function collects all accumulated balance from two matched users and creates a shared multisig wallet with those pooled funds.

  • When users are matched, their balances are reset to zero. If a user matches with multiple people over time, their balance from the first match is lost and not included in subsequent matches, allowing users to receive free dates with unaccounted funds.

function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
// @> Both balances reset to zero
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
// Deploy a MultiSig contract for the matched users
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
// Send ETH to the deployed multisig wallet
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}

Risk

Likelihood:

  • Occurs when a user matches with multiple different users over time

  • Example: User A matches with User B (balance reset), then User A matches with User C (previous balance not included)

Impact:

  • Previously matched users lose their accumulated balance from prior matches

  • Subsequent matches may not receive full expected funds

  • Protocol reward distribution becomes inconsistent

Proof of Concept

function test_UserCanGetFreeDates() public {
// Setup: bob, alice, angie, alex, tony all mint profiles
// bob likes alice (1 ETH)
// bob likes angie (1 ETH) -> bob has 2 ETH
// alice likes bob (1 ETH) -> MATCH
// matchRewards(alice: 1 ETH, bob: 2 ETH) -> multisig gets 2.7 ETH
// bob's balance is reset to 0
// angie likes bob (1 ETH) -> MATCH with bob again
// matchRewards(angie: 1 ETH, bob: 0 ETH) -> multisig gets only 0.9 ETH
// bob's previous 2 ETH contribution is lost
}

Recommended Mitigation

contract LikeRegistry is Ownable {
// Track likes on a per-pair basis instead of per-user
- mapping(address => uint256) public userBalances;
+ mapping(address => mapping(address => uint256)) public userBalances;
function likeUser(address liked) external payable {
// ... validation code ...
+ userBalances[msg.sender][liked] += msg.value;
likes[msg.sender][liked] = true;
// ... emit and matching logic ...
}
function matchRewards(address from, address to) internal {
- uint256 matchUserOne = userBalances[from];
- uint256 matchUserTwo = userBalances[to];
+ uint256 matchUserOne = userBalances[from][to];
+ uint256 matchUserTwo = userBalances[to][from];
userBalances[from][to] = 0;
userBalances[to][from] = 0;
// ... rest of function ...
}
}

M-03: App Owner Can Lock Users' Funds via Profile Blocking

Root + Impact

Description

  • The blockProfile function allows the app owner to permanently block any user by burning their NFT.

  • Users cannot interact with likeUser without a valid profile NFT. When blocked, users lose access to the platform but their ETH deposits remain locked in the contract with no withdrawal mechanism.

function blockProfile(address blockAddress) external onlyOwner {
// @> Owner has unrestricted ability to block any user
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
_burn(tokenId);
delete profileToToken[blockAddress];
delete _profiles[tokenId];
emit ProfileBurned(blockAddress, tokenId);
}
// After blocking, user cannot call:
function likeUser(address liked) external payable {
// @> This check will fail for blocked users
require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
// ... user's funds are now inaccessible
}

Risk

Likelihood:

  • Centralized risk: owner can block users at any time without cause

  • Occurs whenever the owner calls blockProfile on any address

Impact:

  • Users' deposited ETH becomes permanently locked in the contract

  • Users cannot participate in future likes or matches

  • No mechanism for users to recover their funds after being blocked

  • Abuse of centralized power creates trust issues

Proof of Concept

function test_blockProfileAbuseCanCauseFundLoss() public {
vm.deal(bob, 10 ether);
vm.deal(alice, 10 ether);
// mint profiles
vm.prank(bob);
soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage");
vm.prank(alice);
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
// alice sends funds
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
// owner blocks bob
vm.prank(owner);
soulboundNFT.blockProfile(bob);
// bob can no longer interact - his funds are locked
vm.prank(bob);
vm.expectRevert("Must have a profile NFT");
likeRegistry.likeUser{value: 1 ether}(alice);
}

Recommended Mitigation

// Add governance/voting mechanism for profile blocking
+ mapping(address => bool) public proposedBlocks;
+ mapping(address => uint256) public blockVotes;
+ uint256 public constant BLOCK_VOTE_THRESHOLD = 5; // require 5 votes
+ event BlockProposed(address indexed user);
+ event BlockVoteCast(address indexed user, address indexed voter);
- function blockProfile(address blockAddress) external onlyOwner {
+ function proposeBlockProfile(address blockAddress) external onlyOwner {
+ proposedBlocks[blockAddress] = true;
+ emit BlockProposed(blockAddress);
+ }
+ function voteBlockProfile(address blockAddress) external onlyOwner {
+ require(proposedBlocks[blockAddress], "Block not proposed");
+ blockVotes[blockAddress]++;
+ emit BlockVoteCast(blockAddress, msg.sender);
+
+ if (blockVotes[blockAddress] >= BLOCK_VOTE_THRESHOLD) {
+ _executeBlockProfile(blockAddress);
+ }
+ }
+ function _executeBlockProfile(address blockAddress) internal {
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
_burn(tokenId);
delete profileToToken[blockAddress];
delete _profiles[tokenId];
delete proposedBlocks[blockAddress];
delete blockVotes[blockAddress];
emit ProfileBurned(blockAddress, tokenId);
+ }

M-04: Reentrancy in mintProfile Allows Multiple NFT Minting

Root + Impact

Description

  • The mintProfile function calls _safeMint before updating the contract state variables _profiles and profileToToken.

  • Since _safeMint invokes onERC721Received on the recipient (if it's a contract), a malicious contract can reenter mintProfile and mint additional NFTs before the state is updated.

function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
// @> _safeMint can trigger external callbacks
_safeMint(msg.sender, tokenId);
// @> State updated AFTER external call - violates CEI
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Risk

Likelihood:

  • Occurs when a contract address calls mintProfile with a malicious onERC721Received implementation

  • Attacker can reenter during the _safeMint call

Impact:

  • Single address can own multiple NFTs when only one should be allowed

  • Breaks protocol invariant of one profile per address

  • profileToToken mapping only tracks the last minted token, causing inconsistency

  • NFT count and profile count divergence

Proof of Concept

contract MaliciousContract {
SoulboundProfileNFT soulboundNFT;
uint256 counter;
constructor(address _soulboundNFT) {
soulboundNFT = SoulboundProfileNFT(_soulboundNFT);
}
function attack() external {
soulboundNFT.mintProfile("Evil", 99, "malicious.png");
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
// Reenter during the _safeMint callback
if (counter == 0) {
counter++;
soulboundNFT.mintProfile("EvilAgain", 100, "malicious2.png");
}
return 0x150b7a02;
}
}
function testReentrancyMultipleNft() public {
MaliciousContract maliciousContract = new MaliciousContract(
address(soulboundNFT)
);
vm.prank(address(maliciousContract));
MaliciousContract(maliciousContract).attack();
// FAILS: Contract minted 2 NFTs but should only have 1
assertEq(soulboundNFT.balanceOf(address(maliciousContract)), 1);
}

Recommended Mitigation

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);
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
+ _safeMint(msg.sender, tokenId);
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 13 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-01] After the user calls the `likeUser` function, the userBalance does not increase by the corresponding value.

## Description User A calls `likeUser` and sends `value > 1` ETH. According to the design of DatingDapp, the amount for user A should be accumulated by `userBalances`. Otherwise, in the subsequent calculations, the balance for each user will be 0. ## Vulnerability Details When User A calls `likeUser`, the accumulation of `userBalances` is not performed. ```solidity 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"); likes[msg.sender][liked] = true; emit Liked(msg.sender, liked); // Check if mutual like if (likes[liked][msg.sender]) { matches[msg.sender].push(liked); matches[liked].push(msg.sender); emit Matched(msg.sender, liked); matchRewards(liked, msg.sender); } } ``` This will result in `totalRewards` always being 0, affecting all subsequent calculations: ```solidity uint256 totalRewards = matchUserOne + matchUserTwo; uint256 matchingFees = (totalRewards * FIXEDFEE ) / 100; uint256 rewards = totalRewards - matchingFees; totalFees += matchingFees; ``` ## POC ```solidity function testUserBalanceshouldIncreaseAfterLike() public { vm.prank(user1); likeRegistry.likeUser{value: 20 ether}(user2); assertEq(likeRegistry.userBalances(user1), 20 ether, "User1 balance should be 20 ether"); } ``` Then we will get an error: ```shell [FAIL: User1 balance should be 20 ether: 0 != 20000000000000000000] ``` ## Impact - Users will be unable to receive rewards. - The contract owner will also be unable to withdraw ETH from the contract. ## Recommendations Add processing for `userBalances` in the `likeUser` function: ```diff 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"); likes[msg.sender][liked] = true; + userBalances[msg.sender] += msg.value; emit Liked(msg.sender, liked); [...] } ```

Support

FAQs

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

Give us feedback!