DatingDapp

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

H-01: likeUser() fails to credit userBalances, causing matched users to receive 0 ETH

H-01: likeUser() fails to credit userBalances, causing matched users to receive 0 ETH

Severity: High

Summary

When a user calls likeUser() and sends ETH, the contract does not increase userBalances[msg.sender]. As a result, when a mutual like occurs and matchRewards() is executed, both users’ recorded balances can remain 0, so the newly created multisig wallet receives 0 ETH instead of the intended pooled like payments minus fees.

This is a fund-accounting flaw. Solidity’s documentation emphasizes that contract state is the source of truth for assets and that balance-related invariants must be maintained correctly; otherwise, the contract can hold Ether that is not reflected in internal accounting. ([Solidity][1])


Vulnerability Details

In LikeRegistry.likeUser():

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

The function accepts ETH but never does:

userBalances[msg.sender] += msg.value;

Later, matchRewards() calculates rewards only from userBalances:

uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
...
uint256 totalRewards = matchUserOne + matchUserTwo;

So the contract receives Ether, but its accounting layer does not record that Ether for the users who paid it. This breaks the intended reward flow and can leave Ether stranded in the registry contract. Solidity’s docs also note that low-level value transfers are sensitive and must be paired with correct state handling. ([Solidity][1])


Impact

Two users can each pay at least 1 ETH, successfully match, and still receive 0 ETH in their shared multisig wallet. The intended “first date pool” is never funded even though the registry contract has already collected the funds.

This creates two concrete risks:

  1. Loss of intended user funds utility: matched users do not receive the ETH they paid toward the match.

  2. Accounting mismatch / stuck Ether: address(this).balance can exceed the sum of tracked liabilities because deposits are accepted but not assigned to userBalances.

Given that handling and tracking Ether correctly is core to this protocol’s functionality, this issue is high severity. ([Solidity][1])


Root Cause

The contract updates the social state (likes, matches) but does not update the financial state (userBalances) when receiving ETH in likeUser().


Internal Preconditions

  • LikeRegistry is deployed with a valid SoulboundProfileNFT address.

  • Both users own profile NFTs.

  • likeUser() is callable and payable.

External Preconditions

  • User A sends at least 1 ETH to like User B.

  • User B later sends at least 1 ETH to like User A.


Attack Path / Failure Path

  1. User A calls likeUser(B) with 1 ETH.

  2. Contract records the like, but does not increment userBalances[A].

  3. User B calls likeUser(A) with 1 ETH.

  4. Contract records the mutual match and calls matchRewards(B, A).

  5. matchRewards() reads userBalances[B] == 0 and userBalances[A] == 0.

  6. totalRewards becomes 0.

  7. A MultiSigWallet is deployed and funded with 0 ETH.

  8. The LikeRegistry contract still holds the ETH paid by the users, but that ETH is not represented in userBalances.


Proof of Concept

The following Foundry test demonstrates the issue.

function test_MatchRewardsGetsZeroBecauseUserBalancesNeverIncrease() public {
address alice = address(0xA11CE);
address bob = address(0xB0B);
// Deploy NFT and registry
SoulboundProfileNFT nft = new SoulboundProfileNFT();
LikeRegistry registry = new LikeRegistry(address(nft));
// Mint profiles for both users
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(bob);
nft.mintProfile("Bob", 26, "ipfs://bob");
// Alice likes Bob with 1 ETH
vm.deal(alice, 1 ether);
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
// Bob likes Alice with 1 ETH, creating a match
vm.deal(bob, 1 ether);
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
// BUG: balances were never credited
assertEq(registry.userBalances(alice), 0);
assertEq(registry.userBalances(bob), 0);
// Contract still holds the ETH
assertEq(address(registry).balance, 2 ether);
// But the matched users' shared reward pool received 0
// (In the current implementation, the multisig address is not stored or emitted,
// which is itself an observability issue. Still, the zero userBalances prove
// that totalRewards was computed as 0.)
}

PoC Explanation

This test shows that:

  • both users successfully pay 1 ETH,

  • a match occurs,

  • both userBalances remain 0,

  • the registry contract still holds the 2 ETH,

  • therefore the match reward calculation uses zero-value balances and cannot fund the multisig as intended.


Recommended Mitigation

Credit the sender’s balance inside likeUser() before match settlement:

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");
userBalances[msg.sender] += msg.value;
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);
}
}

If the protocol is intended to charge exactly 1 ETH per like, change the validation to:

require(msg.value == 1 ether, "Must send exactly 1 ETH");

That reduces ambiguity and simplifies accounting.

It is also advisable to assert an accounting invariant in tests, such as ensuring that contract-held Ether equals the sum of tracked liabilities plus fees, since Solidity recommends careful reasoning about state and Ether flows. ([Solidity][1])


Additional Note

Because the multisig wallet address is not stored or emitted, it is hard for users and tests to verify where funds were sent. Emitting the wallet address in an event would make settlement observable and much easier to audit.

A good event would be:

event MatchWalletCreated(
address indexed user1,
address indexed user2,
address wallet,
uint256 fundedAmount
);
Updates

Lead Judging Commences

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