matchRewards() wipes a user’s prior deposits after their first match, enabling later matches to be funded only by the counterpartySeverity: Medium
LikeRegistry::matchRewards() resets both matched users’ userBalances to zero after a match. If a user later forms another mutual match, none of that user’s prior like deposits contribute to the new shared multisig wallet. In effect, a repeat matcher can receive a later “date wallet” funded entirely by the other user’s deposit while contributing 0 from their side.
This is a business-logic flaw in reward accounting. Solidity’s security guidance emphasizes maintaining correct state invariants for funds and value flows; here, the contract’s accounting model does not align with the likely protocol expectation that each successful match should pool the relevant like payments from both sides. ([Solidity][1])
As written, your current code has a more severe upstream bug: likeUser() never credits userBalances[msg.sender], so in practice matchRewards() usually sees both users at 0 already. That makes this finding partially masked by H-01, but the logic flaw in matchRewards() is still real on its own and would remain after fixing H-01.
In matchRewards():
The contract treats userBalances[user] as a single rolling bucket for all like payments by that user, not a per-target or per-match deposit. Once a match happens, the entire balance for both users is wiped.
That creates the following flawed behavior:
User A likes User B and deposits funds.
User B likes User A, so they match.
matchRewards() zeros both balances.
Later, User A likes User C.
If User C likes User A back, only the funds deposited after A’s first match are considered.
If the intended protocol behavior is that each like payment should remain associated with the corresponding pending like and contribute to the eventual match for that pair, this implementation is incorrect. Instead, it uses one global per-user balance that gets fully consumed by the first successful match.
A user who has already matched once can participate in later matches without contributing any previously accumulated deposit to those later shared wallets. Depending on protocol intent, this can produce “free date” outcomes where:
one side’s share in a later match is effectively zero,
the counterparty funds nearly or entirely the later date wallet,
pooled rewards no longer correspond to the matched pair’s actual like payments.
This undermines the economic fairness of the protocol and breaks the stated mechanism that previous like payments should fund a mutual match.
The accounting model stores value in a single userBalances[address] bucket rather than tracking deposits per pending like relationship or per pair. Then matchRewards() clears the user’s entire bucket on the first match, even though that balance may conceptually relate to multiple different pending likes.
LikeRegistry is deployed and functioning.
userBalances is updated on likes, or H-01 has been fixed.
Users can like multiple different users over time.
A user participates in more than one mutual match over time.
The protocol expects each pair’s like payments to fund that pair’s shared wallet.
Alice likes Bob and deposits 1 ETH.
Bob likes Alice and deposits 1 ETH.
Alice and Bob match; matchRewards() pays out using both balances and then sets both balances to 0.
Later, Alice likes Carol.
Alice’s prior funds are no longer represented in state because they were fully wiped by the first match.
If Carol likes Alice back, the second date wallet is funded only by whatever new balance Alice has accumulated after the first match plus Carol’s contribution.
If Alice has not contributed fresh funds for this second relationship under the intended accounting model, the match becomes economically skewed.
This PoC assumes H-01 is fixed by adding:
inside likeUser().
Then the following Foundry test demonstrates the flawed accounting model:
This test shows that after Alice’s first successful match, her balance is completely reset. On a later match with Carol, only the funds deposited after that reset are available. None of Alice’s earlier deposits remain attributable to later pending relationships.
That means the contract does not track deposits at the pair level, and a user’s first match can consume value that the protocol may have intended to remain tied to other pending likes.
Your app description says that if a like is mutual, previous like payments are pooled into a shared wallet for the pair. That language strongly suggests pair-specific accounting, not one global per-user balance that gets consumed by the first match. Under that intended design, the current implementation is incorrect.
Track deposits per pair, not per user.
A better model is something like:
Then in likeUser():
And on a match:
This ensures each shared multisig wallet is funded only by the two matched users’ actual deposits for each other, rather than by a global rolling balance.
If the protocol instead intends a user-wide “dating wallet” model, then the docs/spec should be updated to reflect that behavior clearly.
## 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; // ... } } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.