DatingDapp

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

Logic Flaw in matchRewards Resets Entire User Balance — Second Match Is Funded for Free

matchRewards Wipes Cumulative userBalances on First Match, Letting Users Get Subsequent Dates for Free

Description

  • When a mutual like is detected, matchRewards is supposed to pool both users' accumulated ETH contributions (all their previous like payments) minus a 10% fee into
    a shared MultiSigWallet for their first date.

  • matchRewards resets userBalances[from] and userBalances[to] to 0 after the first match. If either user goes on to accumulate more ETH from new likes and later
    matches with a different person, their userBalances is already 0 from the previous reset — so the new MultiSig is funded entirely by the other person's balance. The
    user effectively gets a free second date, paying nothing into the pool despite having sent ETH for additional likes.

function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
@> userBalances[from] = 0; // wipes ALL accumulated balance, not just the matched like
@> 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");                                                                                                                             

}

The vulnerable sequence:

  1. Bob likes Alice → userBalances[bob] += 1 ETH

  2. Bob likes Angie → userBalances[bob] += 1 ETH (total 2 ETH)

  3. Alice likes Bob → first match → userBalances[bob] reset to 0

  4. Angie likes Bob → second match → userBalances[bob] = 0, Angie funds the MultiSig alone

Risk

Likelihood:

  • Any user who participates in more than one match triggers this — common in an active dating platform where users like multiple people before finding mutual
    connections.

  • The flaw becomes exploitable the moment H-01 is fixed and userBalances starts being credited correctly.

Impact:

  • A user matched more than once contributes 0 ETH to every match after their first, while the other party funds the MultiSig entirely from their own balance.

  • The 10% protocol fee is also undercollected on every affected match since totalRewards is artificially reduced.

Proof of Concept

function test_M02_UserGetsFreeDate() public {
// Setup profiles
vm.prank(bob); soulboundNFT.mintProfile("Bob", 25, "ipfs://bob");
vm.prank(alice); soulboundNFT.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(angie); soulboundNFT.mintProfile("Angie", 25, "ipfs://angie");

  vm.deal(bob,   10 ether);                                                                                                                                        
  vm.deal(alice, 10 ether);                             
  vm.deal(angie, 10 ether);                                                                                                                                        
                                                        
  // Bob likes Alice and Angie — accumulates 2 ETH                                                                                                                 
  vm.prank(bob); likeRegistry.likeUser{value: 1 ether}(alice);
  vm.prank(bob); likeRegistry.likeUser{value: 1 ether}(angie);                                                                                                     
                                                                                                                                                                   
  // Alice likes Bob — FIRST MATCH — userBalances[bob] reset to 0                                                                                                  
  vm.prank(alice); likeRegistry.likeUser{value: 1 ether}(bob);                                                                                                     
  // First MultiSig funded correctly (bob: 2 ETH + alice: 1 ETH - 10% = 2.7 ETH)                                                                                   
                                                                                                                                                                   
  // Angie likes Bob — SECOND MATCH — userBalances[bob] is already 0                                                                                               
  vm.prank(angie); likeRegistry.likeUser{value: 1 ether}(bob);                                                                                                     
  // Second MultiSig funded only by Angie (3 ETH - 10% = 2.7 ETH)                                                                                                  
  // Bob contributed 0 ETH despite having paid 1 ETH to like Angie                                                                                                 

}

Recommended Mitigation

Track balances per like pair instead of per user, so a match only consumes the ETH paid for that specific like:

  • mapping(address => uint256) public userBalances;

  • mapping(address => mapping(address => uint256)) public userBalances;

function likeUser(address liked) external payable {
// ...

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

  • userBalances[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 = userBalances[from][to];

  • uint256 matchUserTwo = userBalances[to][from];

  • userBalances[from][to] = 0;

  • userBalances[to][from] = 0;
    // ...
    }


Updates

Lead Judging Commences

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