DatingDapp

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

submitTransaction does not validate _value against the wallet balance, creating permanently stuck approved transactions

Root + Impact

Description

  • MultiSigWallet.submitTransaction is designed to queue a transfer for dual-owner approval before execution, with the expectation that approved transactions can always be executed.

  • There is no check that _value <= address(this).balance. Both owners can approve a transaction whose value exceeds the wallet balance; executeTransaction then reverts on the .call, but the transaction persists in a fully-approved-but-unexecutable state with no cancellation mechanism.

function submitTransaction(address _to, uint256 _value) external onlyOwners {
if (_to == address(0)) revert InvalidRecipient();
if (_value == 0) revert InvalidAmount();
@> // No check: require(_value <= address(this).balance)
transactions.push(Transaction(_to, _value, false, false, false));
uint256 txId = transactions.length - 1;
emit TransactionCreated(txId, _to, _value);
}

Risk

Likelihood:

  • A transaction is submitted for a value larger than the wallet balance — either by mistake, or deliberately by one owner to prevent the other from recovering funds.

  • The wallet balance decreases between submission and execution (e.g., a prior transaction drains funds).

Impact:

  • Owners waste gas approving transactions that can never execute.

  • The transactions array fills with permanently stuck entries since there is no cancelTransaction function.

  • The wallet can be rendered completely inoperable if the only submittable transactions are blocked.

Proof of Concept

This test shows the full lifecycle of a stuck transaction — submission, dual approval, failed execution, and the inability to cancel:

Setup — A MultiSigWallet is funded with exactly 1 ETH.

Oversize submission — owner1 calls submitTransaction(recipient, 10 ether). No revert occurs. The transaction is queued with value = 10 ETH despite the wallet holding only 1 ETH.

Both owners approve — approveTransaction(0) is called by each owner. No revert — neither approval knows about the balance. The transaction is now fully approved.

Execution fails — executeTransaction(0) is called. The .call{value: 10 ether} fails because the wallet balance is 1 ETH. require(success) reverts with "Transaction failed". txn.executed remains false.

Permanently stuck — The transaction cannot be re-executed (same failure), cannot be cancelled (no function exists), and occupies a permanent slot in the transactions array.

To run: forge test --match-test test_stuckApprovedTransaction -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/MultiSig.sol";
contract M3_UnexecutableTxTest is Test {
MultiSigWallet wallet;
address owner1 = makeAddr("owner1");
address owner2 = makeAddr("owner2");
address recipient = makeAddr("recipient");
function setUp() public {
wallet = new MultiSigWallet(owner1, owner2);
vm.deal(address(wallet), 1 ether); // wallet has only 1 ETH
}
function test_stuckApprovedTransaction() public {
// Step 1: Submit a transaction for 10 ETH — no balance check, no revert
vm.prank(owner1);
wallet.submitTransaction(recipient, 10 ether);
// Step 2: Both owners approve — no revert, approval is blind to balance
vm.prank(owner1);
wallet.approveTransaction(0);
vm.prank(owner2);
wallet.approveTransaction(0);
// Step 3: Execution fails — wallet only has 1 ETH
vm.prank(owner1);
vm.expectRevert("Transaction failed");
wallet.executeTransaction(0);
// Step 4: Transaction is permanently stuck — approved but unexecutable, no cancel function
(, , bool approvedByOwner1, bool approvedByOwner2, bool executed) = wallet.transactions(0);
assertTrue(approvedByOwner1, "Approved by owner1");
assertTrue(approvedByOwner2, "Approved by owner2");
assertFalse(executed, "Not executed — stuck forever");
console.log("Transaction stuck: fully approved but unexecutable, no cancellation possible");
}
}

Recommended Mitigation

Add a balance guard at submission time and a cancelTransaction function for recovery:

function submitTransaction(address _to, uint256 _value) external onlyOwners {
if (_to == address(0)) revert InvalidRecipient();
if (_value == 0) revert InvalidAmount();
+ require(_value <= address(this).balance, "Insufficient wallet balance");
transactions.push(Transaction(_to, _value, false, false, false));
uint256 txId = transactions.length - 1;
emit TransactionCreated(txId, _to, _value);
}
+/// @notice Cancel a pending (unexecuted) transaction
+function cancelTransaction(uint256 _txId) external onlyOwners {
+ require(_txId < transactions.length, "Invalid transaction ID");
+ Transaction storage txn = transactions[_txId];
+ require(!txn.executed, "Transaction already executed");
+ txn.executed = true; // mark as consumed so it cannot be re-approved
+ emit TransactionCancelled(_txId);
+}
Updates

Lead Judging Commences

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