DatingDapp

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

MultiSigWallet::submitTransaction allows proposals exceeding wallet balance — executeTransaction will revert, permanently locking funds

Root + Impact

Description

  • The submitTransaction function allows either owner to propose a transaction with any _value, including values greater than the wallet's current balance. The executeTransaction function only checks that both owners have approved the transaction — it does not validate that address(this).balance >= txn.value before calling payable(txn.to).call{value: txn.value}.

    If the _value exceeds the contract's balance, the low-level call will fail and the require(success) will revert. However, the transaction remains in the transactions array with executed = false. If both owners have already approved it, they cannot revoke their approvals (there is no revoke function), so the only option is to submit a new transaction.

    More critically, a malicious co-owner can grief by continuously submitting transactions with inflated values that will always fail on execution. Since there is no mechanism to cancel or remove transactions, and no way to withdraw funds except through the multisig approval process, a single malicious owner can effectively block the other owner from accessing any funds.

// Root cause in MultiSig.sol, submitTransaction (lines 41-48)
function submitTransaction(address _to, uint256 _value) external onlyOwners {
if (_to == address(0)) revert InvalidRecipient();
if (_value == 0) revert InvalidAmount();
// @> No check: _value <= address(this).balance
transactions.push(Transaction(_to, _value, false, false, false));
// ...
}
// executeTransaction (lines 68-79) — will revert if balance insufficient
function executeTransaction(uint256 _txId) external onlyOwners {
// ...
(bool success,) = payable(txn.to).call{value: txn.value}("");
require(success, "Transaction failed");
// @> Reverts if balance < txn.value, but txn approvals are already consumed
}

Risk

Likelihood:

  • This occurs whenever a transaction is submitted with a value exceeding the contract's balance, either by mistake or maliciously.

  • A malicious co-owner (matched user who turns adversarial) has direct incentive to grief the other party.

Impact:

  • Funds can be permanently locked if a malicious co-owner refuses to cooperate on valid transactions.

  • There is no mechanism to cancel pending transactions or revoke approvals, compounding the griefing attack.

Proof of Concept

This test deploys a MultiSig with 1 ETH, then Bob submits a transaction for 100 ETH. Both owners approve, but execution reverts due to insufficient balance. The transaction is now stuck — both approvals are consumed, there is no revoke function, and Alice's funds are locked unless Bob cooperates on a new transaction.

function testH04_MaliciousOwnerGriefs() public {
// MultiSig deployed with 1 ETH balance
MultiSigWallet wallet = new MultiSigWallet(alice, bob);
(bool ok,) = payable(address(wallet)).call{value: 1 ether}("");
require(ok);
// Malicious Bob submits a transaction for 100 ETH (wallet only has 1)
vm.prank(bob);
wallet.submitTransaction(bob, 100 ether);
// Both approve
vm.prank(alice);
wallet.approveTransaction(0);
vm.prank(bob);
wallet.approveTransaction(0);
// Execution reverts — insufficient balance
vm.prank(bob);
vm.expectRevert("Transaction failed");
wallet.executeTransaction(0);
// Transaction #0 is stuck: approved but can never execute
// No way to cancel it or revoke approvals
// Alice must hope Bob cooperates on a new valid transaction
}

Recommended Mitigation

Add a balance check in executeTransaction for a clearer error, and implement a mechanism to cancel or revoke transactions. This prevents permanently stuck state and griefing.

function executeTransaction(uint256 _txId) external onlyOwners {
require(_txId < transactions.length, "Invalid transaction ID");
Transaction storage txn = transactions[_txId];
require(!txn.executed, "Transaction already executed");
require(txn.approvedByOwner1 && txn.approvedByOwner2, "Not enough approvals");
+ require(address(this).balance >= txn.value, "Insufficient wallet balance");
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}("");
require(success, "Transaction failed");
}
+ function revokeApproval(uint256 _txId) external onlyOwners {
+ require(_txId < transactions.length, "Invalid transaction ID");
+ Transaction storage txn = transactions[_txId];
+ require(!txn.executed, "Transaction already executed");
+ if (msg.sender == owner1) {
+ require(txn.approvedByOwner1, "Not approved");
+ txn.approvedByOwner1 = false;
+ } else {
+ require(txn.approvedByOwner2, "Not approved");
+ txn.approvedByOwner2 = false;
+ }
+ }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours 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!