As a developer, Ethereum keeps you on your toes. On the plus side, you constantly learn something new and the thrill of the “aha” moments is undeniable.
Today, I want to talk about an interesting scenario that we ran into at Roll with the old Gnosis multisig wallet. This is a story with a happy ending but also a warning to other developers to handle this scenario gracefully.
Note: This is not a vulnerability in the old Gnosis multisig. Rather, developers using this contract need to put in some extra work.
Transaction failures are not uncommon on Ethereum. There could be a number of reasons for it – from hitting an assertion to trying to take actions that the smart contract explicitly forbids (say, trying to run an admin or owner function without being the admin or owner). However, for most regular users, the most common transaction failure is the gas limit.
Because Ethereum is Turing Complete, it needs to address the halting problem, i.e. make sure every program on Ethereum finishes running. Gas is a workaround to address this. Every transaction has a gas limit. If the gas limit is hit, the transaction is reverted.
It is not hard to find these transactions on Ethereum. Here is an example and how it looks like on Etherscan.
You’ll notice two things –
What we encountered was a transaction where the “Status” is “Success” but the transaction still ran out of gas. Huh?
Here’s what that looked like on Etherscan.
Let’s dig in here and see what’s happening behind the scenes.
The main call we make when confirming a transaction on the old Gnosis multisig wallet is confirmTransaction. This is how it looks –
function confirmTransaction(uint transactionId)
public
ownerExists(msg.sender)
transactionExists(transactionId)
notConfirmed(transactionId, msg.sender)
{
confirmations[transactionId][msg.sender] = true;
Confirmation(msg.sender, transactionId);
executeTransaction(transactionId);
}
As you’ll see, this function is doing two things –
These are not handled atomically, which means it is possible for the confirmation to succeed and the execute to fail. This is exactly what happened in our case.
This is important to note for a few reasons. Firstly, you cannot get out of this situation by resubmitting the transaction with a higher gas limit. If you try, it will just end up failing. The reason is that the transaction is trying to add a confirmation first. If the transaction is already confirmed, then it will simply fail. See the function isConfirmed
function isConfirmed(uint transactionId)
public
constant
returns (bool)
{
uint count = 0;
for (uint i=0; i<owners.length; i++) {
if (confirmations[transactionId][owners[i]])
count += 1;
if (count == required)
return true;
}
}
Second and most importantly, do not try to submit a new multisig transaction. This could result in a double-spend!
A naive and tempting way to handle this situation would be to say, “Well, I guess the transaction didn’t go through and I cannot resubmit it. Let me send a new transaction”. Don’t do this.
To understand why, here’s a hypothetical. Say you have a contract with a call paySalary that does some computation and pays out someone’s salary. This is controlled by an old Gnosis multisig wallet as 2-of-3, so both CEO and CFO need to sign off.
Say you run into the above situation and the transaction doesn’t seem to go through (you check on the blockchain that indeed the Ether or DAI have not been transferred) and naively submit a new transaction with a different transaction id.
In the above scenario, the older transaction will be in confirmed state but not executed state, while the new transaction will be both confirmed and executed. This means the recipient can then call executeTransaction on the contract and be paid twice – essentially a double-spend. So this is a bad way to handle things.
In the scenario outlined above, the transaction ended up in a confirmed state, but not executed. Essentially, the transaction is in the following state
Your application needs to be able to handle this “intermediate” state the right way.
So how should this failure case be handled? You should explicitly call the executeTransaction function on the contract with the transaction id of the original transaction. Here is the function
function executeTransaction(uint transactionId)
public
ownerExists(msg.sender)
confirmed(transactionId, msg.sender)
notExecuted(transactionId)
{
if (isConfirmed(transactionId)) {
Transaction storage txn = transactions[transactionId];
txn.executed = true;
if (external_call(txn.destination, txn.value, txn.data.length, txn.data))
Execution(transactionId);
else {
ExecutionFailure(transactionId);
txn.executed = false;
}
}
}
Calling the function above will ensure that the original transaction is executed. Be sure to provide sufficient gas for this call.
If you use the old Gnosis multisig wallet in your application, it would be wise to check the data on your contract. You can loop through all the transaction ids and look at the following values: isConfirmed and executed. Unfortunately, you cannot get the executed value via a Web3 call (there is no isExecuted function provided).
If you come across any transaction that is in the state isConfirmed: true executed: false, that’s where you need to pay attention.
If you find such a transaction, you can make a call to revoke a confirmation that is not yet executed by calling revokeConfirmation. This is the function
function revokeConfirmation(uint transactionId)
public
ownerExists(msg.sender)
confirmed(transactionId, msg.sender)
notExecuted(transactionId)
{
confirmations[transactionId][msg.sender] = false;
Revocation(msg.sender, transactionId);
}