DatingDapp

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

DatingDapp — likeUser Permanently Locks Every 1 ETH Like Deposit

Description

LikeRegistry.likeUser is payable and requires msg.value >= 1 ether, but it never credits userBalances[msg.sender] (or any other ledger). Native ETH still lands in the contract via the payable call. The only outbound paths (matchRewards and withdrawFees) read userBalances / totalFees, both of which stay at zero after a one-way like.

The product docs state that like payments are held until a mutual match, then pooled (minus a 10% fee) into a shared multisig. Because the credit is missing, that invariant is broken on every like: the ETH is untracked surplus with no spender.

Target Asset

  • Contract: LikeRegistry

  • File: src/LikeRegistry.sol

  • Function: likeUser(address liked) (line 31)

  • Related state: userBalances (line 22), totalFees (line 18), matchRewards (line 50), withdrawFees (line 73)

Deep Dive

Intended reserve model (from README + matchRewards):

  1. A like deposits ≥1 ETH and should increase userBalances[liker].

  2. On mutual like, matchRewards snapshots both balances, zeros them, takes FIXEDFEE (10%) into totalFees, and sends the rest to a new MultiSigWallet.

  3. The owner later drains totalFees via withdrawFees.

What the code actually does:

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);
}
}

likeUser never reads or writes userBalances or totalFees. The only write to userBalances is the zeroing in matchRewards:

function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
// ...
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}

withdrawFees only sends totalFees, which is incremented solely from that snapshot:

function withdrawFees() external onlyOwner {
require(totalFees > 0, "No fees to withdraw");
uint256 totalFeesToWithdraw = totalFees;
totalFees = 0;
(bool success,) = payable(owner()).call{value: totalFeesToWithdraw}("");
require(success, "Transfer failed");
}

After a one-way like:

  • address(this).balance increases by msg.value

  • userBalances[*] == 0

  • totalFees == 0withdrawFees reverts No fees to withdraw

  • matchRewards is not entered

If a later mutual like does enter matchRewards, both snapshots are still 0, so rewards == 0 and matchingFees == 0. The previously deposited ETH remains in the registry forever. There is no rescue / sweep / user withdraw.

Solvency invariant that fails:

address(registry).balance == Σ userBalances + totalFees

After one 1 ETH like: 1e18 != 0.

Exploitation / Steps to Reproduce

No privilege, flash loan, or collusion is required. An honest one-way like is enough.

  1. Deploy SoulboundProfileNFT and LikeRegistry(profileNFT).

  2. Alice calls mintProfile("Alice", 25, "ipfs://alice").

  3. Bob calls mintProfile("Bob", 26, "ipfs://bob").

  4. Alice calls likeUser(Bob) with value: 1 ether.

  5. Observe:

  • Liked is emitted; matchRewards is not entered

  • address(LikeRegistry).balance == 1 ether

  • userBalances[Alice] == 0, userBalances[Bob] == 0

  • withdrawFees() reverts No fees to withdraw

  1. Optional: Bob likes Alice back. A MultiSigWallet is deployed and sent 0 ETH. The 2 ETH (Alice’s + Bob’s) stays locked in LikeRegistry.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/LikeRegistry.sol";
import "../src/SoulboundProfileNFT.sol";
contract LikeRegistryInvariantTest is Test {
SoulboundProfileNFT nft;
LikeRegistry registry;
address alice = address(0xA11CE);
address bob = address(0xB0B);
address owner;
function setUp() public {
nft = new SoulboundProfileNFT();
registry = new LikeRegistry(address(nft));
owner = registry.owner();
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function testOneWayLikeBreaksSolvencyInvariant() public {
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(bob);
nft.mintProfile("Bob", 26, "ipfs://bob");
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
assertEq(registry.userBalances(alice), 0);
assertEq(registry.userBalances(bob), 0);
vm.prank(owner);
vm.expectRevert("No fees to withdraw");
registry.withdrawFees();
uint256 accounted = registry.userBalances(alice) + registry.userBalances(bob);
// INV-001: native balance must equal accounted claims
assertEq(address(registry).balance, accounted); // 1e18 != 0
}
}

Run:

forge test --match-test testOneWayLikeBreaksSolvencyInvariant -vvv

Expected: assertion fail 1e18 != 0. Trace shows Liked emitted, matchRewards not entered, withdrawFees revert, registry balance 1 ether.

Impact

Critical — permanent loss of funds (broken reserve accounting).

  • Every like principal (≥1 ETH per call) is untracked surplus with no outbound path.

  • Users cannot recover a one-way like.

  • On a later match, matchRewards sends 0 because both userBalances are 0, so the date-fund multisig is empty and the 10% protocol fee is never accrued.

  • The owner cannot withdrawFees the stranded ETH (totalFees == 0).

  • Any profiled user reproduces this with a single 1 ETH like. At intended mainnet usage this drains every like the protocol ever receives.

Remediation / Suggested Fix

Credit the deposit before the match check, and keep the solvency invariant test:

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; // credit the like stake
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);
}
}

Also consider:

  • Enforce msg.value == 1 ether (or document overpay as intentional stake).

  • Add assert(address(this).balance >= sum(userBalances) + totalFees) (or a Foundry invariant) so a missing credit cannot ship again.

  • Decide whether unmatched likes should be user-withdrawable after a timeout; today there is still no unmatched-withdraw even after the credit fix, which may be intended.

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!