DatingDapp

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

LikeRegistry::matchRewards sends excess ETH (above 1 ETH per like) to MultiSig while withdrawFees only accounts for fee portion — ETH permanently locked

Root + Impact

Description

  • The likeUser function requires msg.value >= 1 ether — note the >=. A user can send 5 ETH, 10 ETH, or any amount above 1 ETH. If userBalances were properly tracked (see H-01), the excess ETH would be credited and eventually forwarded to a MultiSig wallet via matchRewards.

    However, suppose H-01 is fixed and userBalances is properly updated. There is still no mechanism for a user to withdraw ETH from likes that never resulted in a match. Consider: Alice likes Bob, Carol, Dave, and Eve — paying 1 ETH each. Only Eve likes her back. Alice's userBalances would accumulate 4 ETH. On match with Eve, all 4 ETH goes to the MultiSig. But this means ETH intended for other potential matches is swept away. If Alice later also matches with Bob, her balance is already 0, and Bob's match produces an underfunded MultiSig.

    Additionally, if a user sends more than 1 ETH on a like (which the contract allows), there is no refund mechanism for the excess.

// Root cause in LikeRegistry.sol lines 31-32
function likeUser(address liked) external payable {
require(msg.value >= 1 ether, "Must send at least 1 ETH");
// @> No upper bound on msg.value, no refund of excess
// @> All accumulated ETH from ALL likes is swept on FIRST match
}

Risk

Likelihood:

  • Any user who likes multiple people and matches with one of them loses the ETH associated with all their other likes. This is the expected usage pattern of a dating app.

  • Users who send excess ETH above 1 ETH have no refund mechanism.

Impact:

  • Users lose ETH from likes that never resulted in a match, as all their accumulated balance is swept into the first match's MultiSig.

  • Subsequent matches for the same user produce MultiSig wallets with zero or reduced funding.

Proof of Concept

This test shows that Alice likes 3 users (accumulating 3 ETH in userBalances), but when Dave matches first, matchRewards sweeps all 3 ETH into the Dave–Alice MultiSig. When Bob matches later, Alice's balance is already 0, so Bob's MultiSig is underfunded. Whoever matches first effectively steals ETH intended for all future matches.

function testH02_AllBalancesSweptOnFirstMatch() public {
// Setup: all users have profiles
// Alice likes Bob, Carol, and Dave (3 ETH total)
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(carol);
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(dave);
// Alice has accumulated 3 ETH across 3 separate likes
assertEq(likeRegistry.userBalances(alice), 3 ether);
// Dave matches with Alice — ALL 3 ETH is swept into Dave-Alice MultiSig
vm.prank(dave);
likeRegistry.likeUser{value: 1 ether}(alice);
// matchRewards reads: userBalances[alice]=3, userBalances[dave]=1
// totalRewards = 4 ETH, fees = 0.4 ETH, MultiSig gets 3.6 ETH
// Alice's balance is now 0
assertEq(likeRegistry.userBalances(alice), 0);
// Bob now matches with Alice — MultiSig gets almost nothing from Alice
vm.prank(bob);
likeRegistry.likeUser{value: 1 ether}(alice);
// matchRewards reads: userBalances[alice]=0, userBalances[bob]=1
// totalRewards = 1 ETH — Alice's 1 ETH for Bob was stolen by the Dave match
}

Recommended Mitigation

Track per-like ETH amounts rather than a global user balance. When a match occurs, only the ETH from the specific mutual like should be pooled into the MultiSig wallet. This ensures ETH from unmatched likes remains available for future matches or withdrawal.

+ mapping(address => mapping(address => uint256)) public likeDeposits;
function likeUser(address liked) external payable {
require(msg.value >= 1 ether, "Must send at least 1 ETH");
// ...
- userBalances[msg.sender] += msg.value;
+ likeDeposits[msg.sender][liked] = msg.value;
// ...
}
function matchRewards(address from, address to) internal {
- uint256 matchUserOne = userBalances[from];
- uint256 matchUserTwo = userBalances[to];
- userBalances[from] = 0;
- userBalances[to] = 0;
+ uint256 matchUserOne = likeDeposits[from][to];
+ uint256 matchUserTwo = likeDeposits[to][from];
+ likeDeposits[from][to] = 0;
+ likeDeposits[to][from] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
// ...
}
Updates

Lead Judging Commences

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

[M-02] Logic flaw in `LikeRegistry::matchRewards` can lead to users getting free dates

## Description `LikeRegistry::matchRewards` resets the matched users' `userBalances`. However, if a previously matched user gets liked and matched later on, the share multisig wallet will contain no funds from him. Technically, this scenario isn't possible due to a bug where `userBalances` is never updated with user funds, but the flawed logic is still there. ## Vulnerability Details Upon a match, `LikeRegistry::matchRewards` gets called. ```js function matchRewards(address from, address to) internal { uint256 matchUserOne = userBalances[from]; uint256 matchUserTwo = userBalances[to]; // [1] userBalances[from] = 0; userBalances[to] = 0; // [2] uint256 totalRewards = matchUserOne + matchUserTwo; // [3] 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); // [4] // Send ETH to the deployed multisig wallet (bool success,) = payable(address(multiSigWallet)).call{value: rewards}(""); require(success, "Transfer failed"); } ``` `matchRewards` will collect (`[2]`) all their previous like payments (minus a 10% fee `[3]`) and finally create a shared multisig wallet between the two users, which they can access for their first date (`[4]`). Note how `userBalances` is reset at `[1]`. Imagine the following scenario: 1. bob likes alice - `userBalance[bob] += 1 ETH` 2. bob likes angie - `userBalance[bob] += 1 ETH` 3. alice likes bob (match) - `userBalance[alice] += 1 ETH` - reset of `userBalance[alice]` and `userBalance[bob]` 4. angie likes alex - `userBalance[angie] += 1 ETH` 5. angie likes tony - `userBalance[angie] += 1 ETH` 6. angie likes bob (match) - `userBalance[angie] += 1 ETH` (total of 3 ETH) - `userBalance[bob]` is reset from bob's previous match with alice 7. shared multisig wallet is created using only angie's funds ## Proof of Concept To demonstrate the aforementioned scenario, apply the following patch in `LikeRegistry::likeUser` in order to update `userBalances` properly, ```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" ); + userBalances[msg.sender] += msg.value; 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); } } ``` as well as a few logs in `LikeRegistry::matchRewards`: ```js function matchRewards(address from, address to) internal { uint256 matchUserOne = userBalances[from]; uint256 matchUserTwo = userBalances[to]; @> console.log("[LikeRegistry::matchRewards] matchUserOne:", matchUserOne); @> console.log("[LikeRegistry::matchRewards] matchUserTwo:", matchUserTwo); userBalances[from] = 0; userBalances[to] = 0; // ... // Deploy a MultiSig contract for the matched users MultiSigWallet multiSigWallet = new MultiSigWallet(payable(address(this)), from, to); // Send ETH to the deployed multisig wallet (bool success, ) = payable(address(multiSigWallet)).call{ value: rewards }(""); require(success, "Transfer failed"); @> console.log("[LikeRegistry::matchRewards] multiSigWallet balance:", address(multiSigWallet).balance); } ``` Finally, place `test_UserCanGetFreeDates` in `testSoulboundProfileNFT.t.sol`: ```js function test_UserCanGetFreeDates() public { address bob = makeAddr("bob"); address alice = makeAddr("alice"); address angie = makeAddr("angie"); address alex = makeAddr("alex"); address tony = makeAddr("tony"); vm.deal(bob, 10 ether); vm.deal(alice, 10 ether); vm.deal(angie, 10 ether); // mint a profile NFT for bob vm.prank(bob); soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage"); // mint a profile NFT for alice vm.prank(alice); soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage"); // mint a profile NFT for angie vm.prank(angie); soulboundNFT.mintProfile("Angie", 25, "ipfs://profileImage"); // mint a profile NFT for alex vm.prank(alex); soulboundNFT.mintProfile("Alex", 25, "ipfs://profileImage"); // mint a profile NFT for tony vm.prank(tony); soulboundNFT.mintProfile("Tony", 25, "ipfs://profileImage"); // bob <3 alice vm.prank(bob); likeRegistry.likeUser{value: 1 ether}(alice); assertTrue(likeRegistry.likes(bob, alice)); // bob <3 angie vm.prank(bob); likeRegistry.likeUser{value: 1 ether}(angie); assertTrue(likeRegistry.likes(bob, angie)); console.log("====== FIRST MATCH ======"); // alice <3 bob (match) vm.prank(alice); likeRegistry.likeUser{value: 1 ether}(bob); assertTrue(likeRegistry.likes(alice, bob)); // angie <3 alex vm.prank(angie); likeRegistry.likeUser{value: 1 ether}(alex); assertTrue(likeRegistry.likes(angie, alex)); // angie <3 tony vm.prank(angie); likeRegistry.likeUser{value: 1 ether}(tony); assertTrue(likeRegistry.likes(angie, tony)); console.log("\n\n====== SECOND MATCH ======"); // angie <3 bob (match) vm.prank(angie); likeRegistry.likeUser{value: 1 ether}(bob); } ``` and run the test: ```bash $ forge test --mt test_UserCanGetFreeDates -vvv Ran 1 test for test/testSoulboundProfileNFT.t.sol:SoulboundProfileNFTTest [PASS] test_UserCanGetFreeDates() (gas: 2274150) Logs: ====== FIRST MATCH ====== [LikeRegistry::matchRewards] matchUserOne: 2000000000000000000 [LikeRegistry::matchRewards] matchUserTwo: 1000000000000000000 [LikeRegistry::matchRewards] totalFees: 300000000000000000 [LikeRegistry::matchRewards] multiSigWallet balance: 2700000000000000000 ====== SECOND MATCH ====== [LikeRegistry::matchRewards] matchUserOne: 0 [LikeRegistry::matchRewards] matchUserTwo: 3000000000000000000 [LikeRegistry::matchRewards] totalFees: 600000000000000000 [LikeRegistry::matchRewards] multiSigWallet balance: 2700000000000000000 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.57ms (1.93ms CPU time) Ran 1 test suite in 139.45ms (7.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` Note how on the second match (between angie and bob), `matchUserOne` (which corresponds to bob) is 0. It was reset upon his match with alice. ## Impact Users can get free dates. The impact is low since this bug isn't technically feasible due to a bug in the `LikeRegistry` contract where `userBalances` isn't properly updated with user payments. The logic flaw remains though. ## Recommendations Consider grouping ETH-like payments on a per-like basis instead of all together. ```diff contract LikeRegistry is Ownable { // ... mapping(address => mapping(address => bool)) public likes; mapping(address => address[]) public matches; - mapping(address => uint256) public userBalances; + mapping(address => mapping(address => uint256)) public userBalances; 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][liked] += msg.value; likes[msg.sender][liked] = true; emit Liked(msg.sender, liked); // ... } 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; + userBalances[from][to] = 0; + userBalances[to][from] = 0; // ... } } ```

Support

FAQs

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

Give us feedback!