DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

LikeRegistry likes and matches are keyed by address and never cleared when a profile is burned or blocked, so likes given from or to a deleted profile complete matches for the address's later re-minted profile

Root + Impact

Description

  • A like expresses interest in a profile. A user who deletes their profile with burnProfile ("Allow users to delete their profile") leaves the app. A match should form only when both current profiles have liked each other.

  • LikeRegistry stores consent as likes[liker][liked] = true (L38) and partners in matches[user] (L43-L44). Both are keyed by bare address. No code ever sets a like back to false or removes a match. burnProfile (L49-L51) and blockProfile (L61-L63) clear only NFT-side state, and LikeRegistry has no hook into them. So when an address burns (or is blocked) and then mints a new profile, its old likes carry over, both those it gave and those it received. One like from the counterparty, or from the re-minted address, then completes a match against a profile that was never liked in that direction. It emits Matched and deploys a MultiSigWallet. The NFT is soulbound and identity is the address, so the stale like was paid for by the same key holder. This is stale state surviving profile deletion, not a third party forcing consent.

// src/LikeRegistry.sol L20-L21: likes and matches are keyed by bare address
@> mapping(address => mapping(address => bool)) public likes;
@> mapping(address => address[]) public matches;
​
// src/LikeRegistry.sol L33: a re-minted address that liked someone before cannot like them again
@> require(!likes[msg.sender][liked], "Already liked");
​
// src/LikeRegistry.sol L38-L44: likes are only ever set to true and matches only pushed; nothing resets them
@> 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);
​
// src/SoulboundProfileNFT.sol L49-L51: deleting a profile clears only NFT-side state
@> _burn(tokenId);
@> delete profileToToken[msg.sender];
@> delete _profiles[tokenId];

Risk

Likelihood:

  • It happens when a user who liked someone, or was liked, burns their profile (or is blocked), mints a new profile at the same address, and then one more like is sent in the other direction.

  • That chain needs a voluntary burn and a re-mint, so it is uncommon.

Impact:

  • Match state does not follow the current profiles. In the PoC, a like given from Alice's deleted profile (token 1) completes a match for her new profile (token 3, new name, age and image). That match emits Matched, adds both matches[] entries and deploys a 2-of-2 MultiSigWallet. In the reverse direction, Bob's like of Alice's old profile matches her new profile on her first like.

  • Several side effects follow:

    • Burning a profile does not withdraw interest.

    • getMatches() keeps listing burned and blocked partners.

    • A re-minted address cannot like the same person again (Already liked), even when that person has not liked it back.

    Under the current code no funds move, because every match wallet receives 0 ETH.

Proof of Concept

The PoC deploys SoulboundProfileNFT and LikeRegistry. The NFT is owned by the test contract so that the test can call blockProfile. Matches are detected by counting Matched events from the registry. The baseline shows that a one-sided like creates no match and no wallet. The other tests show the following:

  • Stale outgoing like: Alice likes Bob, then burns her profile; likes(alice, bob) stays true. She mints a new profile (token 3, with a new name, age and image). One like from Bob then:

    • emits Matched;

    • lists the pair in both getMatches();

    • deploys a MultiSigWallet owned by alice and bob.

  • Reverse direction: Bob's like of Alice's old profile matches her new profile on her first like.

  • Cannot re-like: before Bob likes anyone, Alice's like of Bob from her new profile, sent with 1 ETH, reverts Already liked.

  • Stale matches: getMatches() keeps returning a burned partner and a blocked partner.

  • Blocked user: if the owner blocks Alice and she re-mints, she inherits her pre-block like.

// 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";
​
/// DD-07: LikeRegistry state (likes / matches) is keyed by bare address and is never
/// cleared when a profile is burned (burnProfile) or blocked (blockProfile). Consent given
/// from/to a deleted profile therefore carries over to a later, re-minted profile.
contract PoC_DD07 is Test {
SoulboundProfileNFT nft;
LikeRegistry registry;
​
address alice = makeAddr("alice");
address bob = makeAddr("bob");
​
bytes32 constant MATCHED_SIG = keccak256("Matched(address,address)");
​
function setUp() public {
nft = new SoulboundProfileNFT(); // owner = this test contract (for blockProfile)
registry = new LikeRegistry(address(nft));
​
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
​
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://alice-v1");
vm.prank(bob);
nft.mintProfile("Bob", 27, "ipfs://bob");
}
​
// ---------- helpers ----------
function _matchedCount(Vm.Log[] memory logs) internal view returns (uint256 n) {
for (uint256 i; i < logs.length; i++) {
if (logs[i].emitter == address(registry) && logs[i].topics[0] == MATCHED_SIG) n++;
}
}
​
function _firstMultisig() internal view returns (MultiSigWallet) {
// a contract's CREATE nonce starts at 1, so the first wallet deployed by the registry is here
return MultiSigWallet(payable(vm.computeCreateAddress(address(registry), 1)));
}
​
// ---------- baseline: intended behaviour ----------
/// A one-sided like does NOT create a match; no multisig is deployed.
function test_Baseline_OneSidedLikeDoesNotMatch() public {
vm.recordLogs();
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
assertEq(_matchedCount(vm.getRecordedLogs()), 0, "one-sided like must not match");
assertEq(address(_firstMultisig()).code.length, 0, "no wallet deployed");
vm.prank(alice);
assertEq(registry.getMatches().length, 0);
}
​
// ---------- limb 1 + 2: stale outgoing like completes a match for a re-minted profile ----------
function test_StaleLikeFromBurnedProfileCompletesMatchWithNewProfile() public {
// Alice (profile #1) likes Bob
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
uint256 oldTokenId = nft.profileToToken(alice);
assertEq(oldTokenId, 1);
​
// Alice deletes her profile to leave the app
vm.prank(alice);
nft.burnProfile();
assertEq(nft.profileToToken(alice), 0, "profile deleted on NFT side");
// ...but her like is still live in LikeRegistry (limb 2)
assertTrue(registry.likes(alice, bob), "burn did not withdraw the outgoing like");
​
// Alice later mints a completely new profile (new tokenId, new metadata)
vm.prank(alice);
nft.mintProfile("Alicia", 31, "ipfs://totally-different");
uint256 newTokenId = nft.profileToToken(alice);
assertEq(newTokenId, 3, "new identity / tokenId");
assertTrue(newTokenId != oldTokenId);
​
// Bob likes the NEW profile once; Alice never liked Bob from it.
vm.recordLogs();
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
Vm.Log[] memory logs = vm.getRecordedLogs();
​
// A match is created purely from the stale like of the deleted profile
assertEq(_matchedCount(logs), 1, "Matched emitted from stale consent");
vm.prank(bob);
address[] memory bobMatches = registry.getMatches();
assertEq(bobMatches.length, 1);
assertEq(bobMatches[0], alice);
vm.prank(alice);
address[] memory aliceMatches = registry.getMatches();
assertEq(aliceMatches.length, 1);
assertEq(aliceMatches[0], bob);
​
// and a jointly-controlled 2-of-2 wallet is deployed for the pair
MultiSigWallet wallet = _firstMultisig();
assertGt(address(wallet).code.length, 0, "multisig deployed");
assertEq(wallet.owner1(), alice);
assertEq(wallet.owner2(), bob);
console.log("stale-consent match wallet:", address(wallet));
console.log("alice old tokenId / new tokenId:", oldTokenId, newTokenId);
}
​
// ---------- reverse direction: counterparty's like of the OLD profile matches the NEW one ----------
function test_CounterpartyLikeOfOldProfileInstantlyMatchesNewProfile() public {
// Bob likes Alice's ORIGINAL profile ("Alice", 25, alice-v1)
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
​
// Alice burns and re-mints with different name/age/image
vm.prank(alice);
nft.burnProfile();
vm.prank(alice);
nft.mintProfile("NotAlice", 40, "ipfs://new-face");
​
// Bob never saw/liked this new profile, yet Alice's first like matches instantly
vm.recordLogs();
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
assertEq(_matchedCount(vm.getRecordedLogs()), 1, "match from like given to a deleted profile");
assertGt(address(_firstMultisig()).code.length, 0);
}
​
// ---------- limb 3: re-minted address cannot like the same person again ----------
function test_RemintedProfileCannotLikeSamePersonAgain() public {
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
​
vm.prank(alice);
nft.burnProfile();
vm.prank(alice);
nft.mintProfile("Alicia", 31, "ipfs://totally-different");
​
// Bob has NOT liked Alice; this is Alice's first like from her new profile.
assertFalse(registry.likes(bob, alice));
vm.prank(alice);
vm.expectRevert(bytes("Already liked"));
registry.likeUser{value: 1 ether}(bob);
}
​
// ---------- limb 4: getMatches keeps burned / blocked partners ----------
function test_GetMatchesKeepsBurnedAndBlockedPartners() public {
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
​
vm.prank(bob);
nft.burnProfile(); // Bob deletes his profile
nft.blockProfile(alice); // app owner blocks Alice
​
assertEq(nft.profileToToken(bob), 0);
assertEq(nft.profileToToken(alice), 0);
​
vm.prank(alice);
address[] memory aliceMatches = registry.getMatches();
assertEq(aliceMatches.length, 1, "burned partner still listed");
assertEq(aliceMatches[0], bob);
vm.prank(bob);
address[] memory bobMatches = registry.getMatches();
assertEq(bobMatches.length, 1, "blocked partner still listed");
assertEq(bobMatches[0], alice);
}
​
// ---------- limb 5: blocked user re-mints and inherits pre-block likes ----------
function test_BlockedUserRemintsAndInheritsPreBlockLikes() public {
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
​
nft.blockProfile(alice); // owner blocks Alice
assertEq(nft.profileToToken(alice), 0);
assertTrue(registry.likes(alice, bob), "pre-block like survives block");
​
// blockProfile keeps no blocklist, so Alice can simply mint again
vm.prank(alice);
nft.mintProfile("Alice2", 25, "ipfs://alice-v2");
​
vm.recordLogs();
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
assertEq(_matchedCount(vm.getRecordedLogs()), 1, "blocked user's pre-block like produced a match");
}
}

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

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

Result: Suite result: ok. 6 passed; 0 failed; 0 skipped. The logs show alice old tokenId / new tokenId: 1 3.

Recommended Mitigation

Key likes by profile token id instead of by address. Token ids come from a counter and are never reused, so a new profile starts with no likes, given or received. getMatches() can similarly skip partners whose profileToToken is 0.

--- a/src/LikeRegistry.sol
+++ b/src/LikeRegistry.sol
@@ -17,7 +17,8 @@
uint256 immutable FIXEDFEE = 10;
uint256 totalFees;
- mapping(address => mapping(address => bool)) public likes;
+ // Keyed by profile token id: ids are never reused, so a burned profile's likes cannot reach a new profile
+ mapping(uint256 => mapping(uint256 => bool)) public likes;
mapping(address => address[]) public matches;
mapping(address => uint256) public userBalances;
@@ -29,17 +30,19 @@
}
function likeUser(address liked) external payable {
+ uint256 likerId = profileNFT.profileToToken(msg.sender);
+ uint256 likedId = profileNFT.profileToToken(liked);
require(msg.value >= 1 ether, "Must send at least 1 ETH");
- require(!likes[msg.sender][liked], "Already liked");
+ require(!likes[likerId][likedId], "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");
+ require(likerId != 0, "Must have a profile NFT");
+ require(likedId != 0, "Liked user must have a profile NFT");
- likes[msg.sender][liked] = true;
+ likes[likerId][likedId] = true;
emit Liked(msg.sender, liked);
// Check if mutual like
- if (likes[liked][msg.sender]) {
+ if (likes[likedId][likerId]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!