DatingDapp

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

LikeRegistry::likeUser never credits msg.value to userBalances, so every like payment is locked in LikeRegistry forever, every match multisig is funded with 0 ETH, and withdrawFees always reverts

Root + Impact

Description

  • A user pays at least 1 ETH to like another profile. The README says: "If the like is mutual, all their previous like payments (minus a 10% fee) are pooled into a shared multisig wallet, which both users can access for their first date." matchRewards is written to do this. It reads both users' userBalances, adds 10% to totalFees, deploys a MultiSigWallet for the pair and sends it the other 90%. The owner collects the fees with withdrawFees.

  • likeUser checks msg.value (L32) but never records it. userBalances (L22) is never incremented anywhere in the codebase; its only uses are the reads and zeroing in matchRewards (L51-L54). So totalRewards, matchingFees and rewards are always 0. totalFees never grows, every match multisig is sent 0 wei (L65), and withdrawFees always reverts at L74. LikeRegistry has only these two ETH outflows, and there is no refund, sweep or rescue function. Every wei paid to likeUser, including any amount above 1 ETH, therefore stays in the contract permanently.

// src/LikeRegistry.sol L22: the per-user balance that should hold like payments
@> mapping(address => uint256) public userBalances;
​
// src/LikeRegistry.sol L31-L39: the payment is checked but never recorded
function likeUser(address liked) external payable {
@> require(msg.value >= 1 ether, "Must send at least 1 ETH"); // L32: the only use of msg.value
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; // L38: nothing credits userBalances[msg.sender]
emit Liked(msg.sender, liked);
​
// src/LikeRegistry.sol L50-L59: both balances are always 0, so rewards and fees are always 0
function matchRewards(address from, address to) internal {
@> uint256 matchUserOne = userBalances[from]; // L51
@> uint256 matchUserTwo = userBalances[to]; // L52
userBalances[from] = 0;
userBalances[to] = 0;
​
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
​
// src/LikeRegistry.sol L65: the match multisig is sent 0 wei
@> (bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
​
// src/LikeRegistry.sol L73-L74: the only other ETH outflow always reverts
function withdrawFees() external onlyOwner {
@> require(totalFees > 0, "No fees to withdraw");

Risk

Likelihood:

  • It happens on every call to likeUser, in normal use, with no attacker and no special state: each payment is left uncredited the moment it is made.

  • It happens on every mutual like: each match deploys a MultiSigWallet and sends it rewards, which is always 0.

Impact:

  • All ETH paid to likeUser is permanently locked in LikeRegistry. This covers matched and unmatched likes and any overpayment above 1 ETH. In the PoC, four likes (one of them a 3 ETH overpayment) and two matches leave 6 ETH in the registry that no function can move.

  • Matched couples receive nothing and the owner never earns the 10% fee. Both match multisigs hold 0 wei, and the couple's 1 ETH withdrawal from one of them reverts Transaction failed. totalFees stays 0, so withdrawFees reverts No fees to withdraw. The core value flow the README promises never works.

Proof of Concept

The PoC deploys SoulboundProfileNFT and LikeRegistry directly, with no ffi, scripts or other test files. It also includes FixedLikeRegistry as a baseline. This is a copy of LikeRegistry with only the missing line userBalances[msg.sender] += msg.value; added. On it, a mutual 1 ETH + 1 ETH like sends 1.8 ETH to the multisig, and the owner withdraws 0.2 ETH of fees, leaving the registry empty. The baseline also confirms that the private totalFees is at storage slot 2, which the bug test reads with vm.load.

On the real LikeRegistry, the same flow gives these results:

  • the registry holds 2 ETH;

  • the new multisig holds 0 wei;

  • totalFees is 0;

  • withdrawFees reverts;

  • the couple's approved 1 ETH multisig withdrawal reverts Transaction failed.

The test then covers overpayment and a second match:

  • Carol's 3 ETH unmatched like is not credited either.

  • A second match deploys a second empty multisig, leaving 6 ETH stranded.

// 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";
import "@openzeppelin/contracts/access/Ownable.sol";
​
/// @dev Reference copy of LikeRegistry with ONLY the one-line fix applied
/// (`userBalances[msg.sender] += msg.value;`). Used as the intended-behaviour
/// baseline and to validate the storage slot of the private `totalFees`.
contract FixedLikeRegistry is Ownable {
SoulboundProfileNFT public profileNFT;
uint256 immutable FIXEDFEE = 10;
uint256 totalFees;
mapping(address => mapping(address => bool)) public likes;
mapping(address => address[]) public matches;
mapping(address => uint256) public userBalances;
​
constructor(address _profileNFT) Ownable(msg.sender) {
profileNFT = SoulboundProfileNFT(_profileNFT);
}
​
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; // <-- the missing line
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
matchRewards(liked, msg.sender);
}
}
​
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;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}
​
function withdrawFees() external onlyOwner {
require(totalFees > 0, "No fees to withdraw");
uint256 amt = totalFees;
totalFees = 0;
(bool success,) = payable(owner()).call{value: amt}("");
require(success, "Transfer failed");
}
​
receive() external payable {}
}
​
contract PoC_DD01 is Test {
// Storage layout: Ownable._owner (0), profileNFT (1), FIXEDFEE is immutable (no slot), totalFees (2)
bytes32 constant TOTAL_FEES_SLOT = bytes32(uint256(2));
​
SoulboundProfileNFT nft;
LikeRegistry registry;
FixedLikeRegistry fixedRegistry;
​
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
​
function setUp() public {
nft = new SoulboundProfileNFT();
vm.startPrank(owner);
registry = new LikeRegistry(address(nft));
fixedRegistry = new FixedLikeRegistry(address(nft));
vm.stopPrank();
​
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(bob);
nft.mintProfile("Bob", 26, "ipfs://bob");
vm.prank(carol);
nft.mintProfile("Carol", 27, "ipfs://carol");
​
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
vm.deal(carol, 10 ether);
}
​
function _totalFees(address reg) internal view returns (uint256) {
return uint256(vm.load(reg, TOTAL_FEES_SLOT));
}
​
/// Baseline: with the one-line fix, a mutual like pools 1.8 ETH into the
/// multisig and accrues 0.2 ETH of fees that the owner can withdraw.
function test_baseline_fixedRegistryPoolsFundsAndAccruesFees() public {
vm.prank(alice);
fixedRegistry.likeUser{value: 1 ether}(bob);
assertEq(fixedRegistry.userBalances(alice), 1 ether, "fixed: alice credited");
​
vm.prank(bob);
fixedRegistry.likeUser{value: 1 ether}(alice);
​
address wallet = vm.computeCreateAddress(address(fixedRegistry), 1);
assertEq(MultiSigWallet(payable(wallet)).owner1(), alice);
assertEq(MultiSigWallet(payable(wallet)).owner2(), bob);
assertEq(wallet.balance, 1.8 ether, "fixed: multisig gets 90% of pooled likes");
assertEq(_totalFees(address(fixedRegistry)), 0.2 ether, "fixed: 10% fee accrued (also validates slot 2)");
​
uint256 before = owner.balance;
vm.prank(owner);
fixedRegistry.withdrawFees();
assertEq(owner.balance - before, 0.2 ether, "fixed: owner withdraws fee");
assertEq(address(fixedRegistry).balance, 0, "fixed: nothing stranded");
​
console.log("FIXED multisig balance (wei):", wallet.balance);
console.log("FIXED fees withdrawn by owner (wei):", owner.balance - before);
}
​
/// The bug: likeUser never credits userBalances, so every like payment is
/// stranded in LikeRegistry, the match multisig receives 0 wei, and
/// withdrawFees always reverts.
function test_DD01_likePaymentsLockedMultisigEmptyFeesUnwithdrawable() public {
// Alice likes Bob: 1 ETH paid, nothing credited
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
assertTrue(registry.likes(alice, bob));
assertEq(registry.userBalances(alice), 0, "alice's 1 ETH like is NOT credited");
assertEq(address(registry).balance, 1 ether);
​
// Bob likes Alice back -> match
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
​
// Match did happen (intended part of the flow works)
vm.prank(alice);
address[] memory m = registry.getMatches();
assertEq(m.length, 1);
assertEq(m[0], bob);
​
// A multisig was deployed for the couple ... but funded with 0 wei
address wallet = vm.computeCreateAddress(address(registry), 1);
assertGt(wallet.code.length, 0, "multisig deployed");
assertEq(MultiSigWallet(payable(wallet)).owner1(), alice);
assertEq(MultiSigWallet(payable(wallet)).owner2(), bob);
assertEq(wallet.balance, 0, "multisig receives 0 wei instead of 1.8 ETH");
​
// All 2 ETH remain in the registry; no fee accrued; balances still 0
assertEq(address(registry).balance, 2 ether, "2 ETH stranded in registry");
assertEq(registry.userBalances(alice), 0);
assertEq(registry.userBalances(bob), 0);
assertEq(_totalFees(address(registry)), 0, "totalFees never grows");
​
// Owner cannot withdraw any fee
vm.prank(owner);
vm.expectRevert(bytes("No fees to withdraw"));
registry.withdrawFees();
​
// The couple cannot pull anything from their multisig either
vm.prank(alice);
MultiSigWallet(payable(wallet)).submitTransaction(alice, 1 ether);
vm.prank(alice);
MultiSigWallet(payable(wallet)).approveTransaction(0);
vm.prank(bob);
MultiSigWallet(payable(wallet)).approveTransaction(0);
vm.prank(alice);
vm.expectRevert(bytes("Transaction failed"));
MultiSigWallet(payable(wallet)).executeTransaction(0);
​
// Growth + overpayment limb: Carol overpays 3 ETH on an unmatched like
vm.prank(carol);
registry.likeUser{value: 3 ether}(alice);
assertEq(registry.userBalances(carol), 0, "overpayment not credited either");
assertEq(address(registry).balance, 5 ether, "locked balance grows with every like");
​
vm.prank(owner);
vm.expectRevert(bytes("No fees to withdraw"));
registry.withdrawFees();
​
// Even if Alice now likes Carol back (a second match), still 0 wei moves
vm.prank(alice);
registry.likeUser{value: 1 ether}(carol);
address wallet2 = vm.computeCreateAddress(address(registry), 2);
assertEq(MultiSigWallet(payable(wallet2)).owner1(), carol);
assertEq(MultiSigWallet(payable(wallet2)).owner2(), alice);
assertEq(wallet2.balance, 0, "second multisig also empty");
assertEq(address(registry).balance, 6 ether, "all 6 ETH of likes stranded");
assertEq(_totalFees(address(registry)), 0);
​
vm.prank(owner);
vm.expectRevert(bytes("No fees to withdraw"));
registry.withdrawFees();
​
console.log("BUGGY registry stranded balance (wei):", address(registry).balance);
console.log("BUGGY multisig #1 balance (wei):", wallet.balance);
console.log("BUGGY multisig #2 balance (wei):", wallet2.balance);
console.log("BUGGY totalFees (wei):", _totalFees(address(registry)));
}
}

Run from the repository root, with the file saved as test/PoC_DD-01.t.sol:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_DD-01.t.sol -vv

Result: Suite result: ok. 2 passed; 0 failed; 0 skipped. The logs show BUGGY registry stranded balance (wei): 6000000000000000000, both multisig balances at 0, and FIXED multisig balance (wei): 1800000000000000000.

Recommended Mitigation

Credit the payment when the like is recorded. This one line is the whole difference between the two registries in the PoC.

--- a/src/LikeRegistry.sol
+++ b/src/LikeRegistry.sol
@@ -36,6 +36,7 @@
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
Updates

Lead Judging Commences

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