DatingDapp

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

userBalances Never Updated leading to Total Fund Loss

Root + Impact

Description

  • When a user calls `likeUser()` with ETH, the contract should credit the sent amount to `userBalances[msg.sender]` so that when a mutual match occurs, the accumulated funds can be distributed to the shared MultiSig wallet.

  • The `likeUser()` function receives ETH via `msg.value` but never updates `userBalances[msg.sender]`. When `matchRewards()` is called on a mutual like, it reads zero balances for both users and sends 0 ETH to the deployed MultiSig. All user ETH remains permanently locked in the LikeRegistry contract with no recovery mechanism.

// LikeRegistry.sol:31-48
function likeUser(address liked) external payable {
@> require(msg.value >= 1 ether, "Must send at least 1 ETH"); // ETH is received here
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);
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
@> // BUG: userBalances[msg.sender] += msg.value; is MISSING
}
// LikeRegistry.sol:50-67
function matchRewards(address from, address to) internal {
@> uint256 matchUserOne = userBalances[from]; // Always 0 - never set
@> uint256 matchUserTwo = userBalances[to]; // Always 0 - never set
userBalances[from] = 0;
userBalances[to] = 0;
@> uint256 totalRewards = matchUserOne + matchUserTwo; // 0 + 0 = 0
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100; // 0
uint256 rewards = totalRewards - matchingFees; // 0
totalFees += matchingFees;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
@> (bool success,) = payable(address(multiSigWallet)).call{value: rewards}(""); // Sends 0 ETH
require(success, "Transfer failed");
}

Risk

Likelihood:

  • Every single call to `likeUser()` triggers this bug - there is no code path that avoids it

  • The vulnerability is deterministic and occurs on 100% of transactions regardless of user behavior

Impact:

  • 100% of user funds sent via `likeUser()` are permanently locked in the contract

  • MultiSig wallets receive 0 ETH regardless of how much users paid, making the protocol completely non-functional

  • No withdrawal or recovery mechanism exists - funds are irrecoverable

  • Protocol fees (`totalFees`) are also 0, so even the owner cannot extract the locked funds

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../../src/SoulboundProfileNFT.sol";
import "../../src/LikeRegistry.sol";
import "../../src/MultiSig.sol";
/**
* @title F-001 PoC: userBalances Never Updated - Total Fund Loss
* @notice Demonstrates that user ETH sent via likeUser() is never credited
* to userBalances, resulting in 0 ETH being sent to MultiSig on match
*/
contract F001_UserBalancesNotUpdatedTest is Test {
SoulboundProfileNFT public profileNFT;
LikeRegistry public likeRegistry;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
// Deploy contracts
profileNFT = new SoulboundProfileNFT();
likeRegistry = new LikeRegistry(address(profileNFT));
// Fund test accounts
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
// Create profiles for both users
vm.prank(alice);
profileNFT.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(bob);
profileNFT.mintProfile("Bob", 27, "ipfs://bob");
}
/**
* @notice CRITICAL: Proves userBalances is never updated
* @dev This test demonstrates the core vulnerability:
* 1. User sends ETH with likeUser()
* 2. userBalances[user] remains 0
* 3. On match, MultiSig receives 0 ETH
* 4. User ETH is permanently locked in LikeRegistry
*/
function testUserBalancesNeverUpdated() public {
// Record initial state
uint256 aliceInitialBalance = alice.balance;
uint256 bobInitialBalance = bob.balance;
uint256 registryInitialBalance = address(likeRegistry).balance;
console.log("=== INITIAL STATE ===");
console.log("Alice balance:", aliceInitialBalance / 1e18, "ETH");
console.log("Bob balance:", bobInitialBalance / 1e18, "ETH");
console.log("LikeRegistry balance:", registryInitialBalance / 1e18, "ETH");
// Step 1: Alice likes Bob with 1 ETH
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
console.log("\n=== AFTER ALICE LIKES BOB ===");
console.log("Alice balance:", alice.balance / 1e18, "ETH");
console.log("LikeRegistry balance:", address(likeRegistry).balance / 1e18, "ETH");
// BUG DEMONSTRATION: userBalances[alice] should be 1 ETH but is 0
uint256 aliceUserBalance = likeRegistry.userBalances(alice);
console.log("userBalances[alice]:", aliceUserBalance / 1e18, "ETH");
assertEq(aliceUserBalance, 0, "BUG: userBalances[alice] should be 0 (not updated)");
// Step 2: Bob likes Alice with 1 ETH (triggers match)
vm.prank(bob);
likeRegistry.likeUser{value: 1 ether}(alice);
console.log("\n=== AFTER BOB LIKES ALICE (MATCH TRIGGERED) ===");
console.log("Bob balance:", bob.balance / 1e18, "ETH");
console.log("LikeRegistry balance:", address(likeRegistry).balance / 1e18, "ETH");
// BUG DEMONSTRATION: Both balances are 0 after match
uint256 aliceUserBalanceAfter = likeRegistry.userBalances(alice);
uint256 bobUserBalanceAfter = likeRegistry.userBalances(bob);
console.log("userBalances[alice] after match:", aliceUserBalanceAfter / 1e18, "ETH");
console.log("userBalances[bob] after match:", bobUserBalanceAfter / 1e18, "ETH");
// Verify match occurred
address[] memory aliceMatches = likeRegistry.getMatches();
vm.prank(alice);
aliceMatches = likeRegistry.getMatches();
assertEq(aliceMatches.length, 1, "Alice should have 1 match");
assertEq(aliceMatches[0], bob, "Alice should be matched with Bob");
// CRITICAL: Try to withdraw fees - should fail because totalFees is 0
// (totalFees is not public, so we verify by attempting withdrawal)
vm.expectRevert("No fees to withdraw");
likeRegistry.withdrawFees();
console.log("withdrawFees() reverts - confirms totalFees is 0");
// CRITICAL: 2 ETH is now locked in LikeRegistry forever
uint256 registryFinalBalance = address(likeRegistry).balance;
console.log("\n=== FUND LOSS SUMMARY ===");
console.log("ETH locked in LikeRegistry:", registryFinalBalance / 1e18, "ETH");
assertEq(registryFinalBalance, 2 ether, "2 ETH should be locked in LikeRegistry");
// There is NO way to retrieve this ETH:
// - userBalances are 0, so no user withdrawal (even if function existed)
// - totalFees is 0, so owner gets nothing
// - No rescue function exists
}
/**
* @notice Demonstrates the exact flow of matchRewards with 0 balances
*/
function testMatchRewardsCalculatesZero() public {
// Setup: Alice likes Bob
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
// Before Bob likes Alice, check the state
uint256 aliceBalance = likeRegistry.userBalances(alice);
uint256 bobBalance = likeRegistry.userBalances(bob);
console.log("Before match:");
console.log(" userBalances[alice] =", aliceBalance);
console.log(" userBalances[bob] =", bobBalance);
// What matchRewards will calculate:
// totalRewards = 0 + 0 = 0
// matchingFees = (0 * 10) / 100 = 0
// rewards = 0 - 0 = 0
// MultiSig receives 0
// Trigger match
vm.prank(bob);
likeRegistry.likeUser{value: 1 ether}(alice);
// Verify 0 fees collected by attempting withdrawal
vm.expectRevert("No fees to withdraw");
likeRegistry.withdrawFees();
}
/**
* @notice Proves excess ETH is also lost
*/
function testExcessEthAlsoLost() public {
uint256 excessAmount = 5 ether;
// Alice sends 5 ETH (more than required 1 ETH)
vm.prank(alice);
likeRegistry.likeUser{value: excessAmount}(bob);
// All 5 ETH is lost, not just 1 ETH
assertEq(address(likeRegistry).balance, excessAmount, "All 5 ETH locked");
assertEq(likeRegistry.userBalances(alice), 0, "userBalances still 0");
}
/**
* @notice Proves there's no way to recover locked funds
*/
function testNoRecoveryMechanism() public {
// Lock 2 ETH in contract
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
vm.prank(bob);
likeRegistry.likeUser{value: 1 ether}(alice);
// Contract has 2 ETH
assertEq(address(likeRegistry).balance, 2 ether);
// Try to withdraw fees as owner - fails because totalFees is 0
vm.expectRevert("No fees to withdraw");
likeRegistry.withdrawFees();
// There is no other function to withdraw ETH
// Funds are permanently locked
}
}

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);
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 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!