DatingDapp

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

MultiSigWallet requires both owners to approve every transaction — lost key or unresponsive owner permanently freezes the match fund

Root + Impact

Description

// MultiSig.sol — executeTransaction()
function executeTransaction(uint256 _txId) external onlyOwners {
require(_txId < transactions.length, "Invalid transaction ID");
Transaction storage txn = transactions[_txId];
require(!txn.executed, "Transaction already executed");
// @> BOTH approvals required — no timelock, no fallback
// @> If one owner is permanently unavailable, this require always fails
require(txn.approvedByOwner1 && txn.approvedByOwner2, "Not enough approvals");
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}("");
require(success, "Transaction failed");
emit TransactionExecuted(_txId, txn.to, txn.value);
}
// @> No timelock mechanism anywhere in the contract
// @> No emergency withdrawal function
// @> No owner replacement mechanism
// @> No 1-of-2 fallback after N days

Risk

Likelihood:

  • Key loss is a routine event in crypto — hardware failures, forgotten seed phrases, and phishing are common. Any matched pair where one party loses key access permanently freezes the fund.

  • Griefing is trivially achievable: a malicious user matches with a victim, then simply refuses to approve any transaction. The victim's 0.9 ETH is frozen and the griefer faces no cost since they cannot spend the funds themselves either — but they have successfully denied the victim access.

  • The MultiSigWallet is deployed for every match — this affects 100% of matched pairs.

Impact:

  • Matched users lose access to their pooled date fund permanently if either party becomes unavailable.

  • A deliberate griefer can permanently freeze their match's funds as a denial-of-service with no cost — they can't take the money but they can prevent anyone from accessing it.

  • No admin recovery path exists — even the LikeRegistry owner cannot access funds inside a deployed MultiSigWallet.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/MultiSig.sol";
contract MultiSigLockTest is Test {
MultiSigWallet wallet;
address owner1 = makeAddr("owner1");
address owner2 = makeAddr("owner2");
function setUp() public {
wallet = new MultiSigWallet(owner1, owner2);
deal(address(wallet), 1 ether);
}
function test_fundsLockedIfOwner2Unresponsive() public {
// Owner1 submits and approves a transaction
vm.prank(owner1);
wallet.submitTransaction(owner1, 1 ether);
vm.prank(owner1);
wallet.approveTransaction(0);
// Owner2 never approves (lost key / griefer / unavailable)
// Execution fails forever — no timeout, no escape hatch
vm.prank(owner1);
vm.expectRevert("Not enough approvals");
wallet.executeTransaction(0);
// Funds permanently locked
assertEq(address(wallet).balance, 1 ether);
}
function test_griefer_freezes_victim_funds() public {
// Owner1 (victim) submits withdrawal to themselves
vm.prank(owner1);
wallet.submitTransaction(owner1, 1 ether);
vm.prank(owner1);
wallet.approveTransaction(0);
// Owner2 (griefer) simply never approves — no penalty, no recourse
// Victim's 0.9 ETH locked indefinitely
assertEq(address(wallet).balance, 1 ether);
}
}

Recommended Mitigation

Add a timelock fallback that allows either owner to execute a transaction unilaterally after a grace period (e.g. 30 days) if the other owner has not responded:

// MultiSig.sol — add timelock recovery
uint256 public constant TIMELOCK = 30 days;
struct Transaction {
address to;
uint256 value;
bool approvedByOwner1;
bool approvedByOwner2;
bool executed;
uint256 submittedAt; // @> add submission timestamp
}
function submitTransaction(address _to, uint256 _value) external onlyOwners {
if (_to == address(0)) revert InvalidRecipient();
if (_value == 0) revert InvalidAmount();
// @> record submission time
transactions.push(Transaction(_to, _value, false, false, false, block.timestamp));
uint256 txId = transactions.length - 1;
emit TransactionCreated(txId, _to, _value);
}
function executeTransaction(uint256 _txId) external onlyOwners {
require(_txId < transactions.length, "Invalid transaction ID");
Transaction storage txn = transactions[_txId];
require(!txn.executed, "Transaction already executed");
bool bothApproved = txn.approvedByOwner1 && txn.approvedByOwner2;
// @> After TIMELOCK, one approval is sufficient as a recovery mechanism
bool timelockExpired = block.timestamp >= txn.submittedAt + TIMELOCK;
bool oneApproved = (txn.approvedByOwner1 || txn.approvedByOwner2);
require(bothApproved || (timelockExpired && oneApproved), "Not enough approvals");
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}("");
require(success, "Transaction failed");
emit TransactionExecuted(_txId, txn.to, txn.value);
}
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!