Root + Impact
Description
The protocol stores every match for each user in a dynamic array. getMatches() returns the entire array for msg.sender in one call.
The array has no maximum size, no pagination, and no removal path when profiles are burned or blocked. A user with many matches can eventually have a match list too large to reliably return through eth_call, and any on-chain integration that calls getMatches() can run out of gas because the full storage array must be copied into memory and returned.
mapping(address => address[]) public matches;
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);
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}
function getMatches() external view returns (address[] memory) {
return matches[msg.sender];
}
Risk
Likelihood:
Impact:
Proof of Concept
function testGetMatchesCostGrowsWithArrayLength() public {
SoulboundProfileNFT nft = new SoulboundProfileNFT();
LikeRegistry registry = new LikeRegistry(address(nft));
address target = address(0xA11CE);
vm.deal(target, 1000 ether);
vm.prank(target);
nft.mintProfile("Target", 25, "ipfs://target");
for (uint160 i = 1; i <= 200; i++) {
address user = address(i);
vm.deal(user, 2 ether);
vm.prank(user);
nft.mintProfile("User", 25, "ipfs://user");
vm.prank(target);
registry.likeUser{value: 1 ether}(user);
vm.prank(user);
registry.likeUser{value: 1 ether}(target);
}
uint256 gasBefore = gasleft();
vm.prank(target);
address[] memory targetMatches = registry.getMatches();
uint256 gasUsed = gasBefore - gasleft();
assertEq(targetMatches.length, 200);
assertGt(gasUsed, 0);
}
The same pattern continues as the array grows. Because the contract only exposes a full-array getter, clients cannot request a bounded slice once the match list becomes large.
Recommended Mitigation
Expose bounded pagination and a match count instead of requiring callers to retrieve the entire array.
-function getMatches() external view returns (address[] memory) {
- return matches[msg.sender];
-}
+function getMatchCount(address user) external view returns (uint256) {
+ return matches[user].length;
+}
+
+function getMatchAt(address user, uint256 index) external view returns (address) {
+ return matches[user][index];
+}
+
+function getMatchesPaginated(address user, uint256 offset, uint256 limit)
+ external
+ view
+ returns (address[] memory page)
+{
+ uint256 length = matches[user].length;
+ if (offset >= length) return new address[](0);
+
+ uint256 end = offset + limit;
+ if (end > length) end = length;
+
+ page = new address[](end - offset);
+ for (uint256 i = offset; i < end; i++) {
+ page[i - offset] = matches[user][i];
+ }
+}