Post-mortem: 29 Jan options exercise bug
Summary
There was a bug in the contract code that led to option holders being unable to redeem their option payoffs. Admin methods were used to unlock the contracts so that users are now able to collect their payoffs. No funds were lost.
Details
Charm was launched on mainnet recently and the first options markets had just expired at 16:00 UTC on 29 January. The settlement process involves fetching the price of ETH from an oracle and using this to calculate how much of the funds in the contract should be paid out each option holders and to each LP. For these first markets, the treasury was the only LP.
The settlement process was going smoothly and option holders were starting to successfully exercise their options and collect their payoffs. I decided to withdraw liquidity back into the treasury so that it could be deposited into the next option markets. In theory, this shouldn’t have affected the option holders’ ability to exercise their options.
However, the next transaction to exercise options failed. Upon investigation, there was a bug which prevented anyone from exercising options if there was no liquidity in the contract.
The sell() method allows users to sell their options back to the AMM before expiry, or exercise their options if expiry has already passed. It burns their option tokens and sends the appropriate amount of ETH back to them.
totalSupply() represents the total supply of LP shares. If there’s no liquidity in the contract, it’ll be equal to 0.
The highlighted line was intended to prevent users from calling sell() before liquidity was initially added, but it also prevents users from exercising their options if all liquidity has been withdrawn.
To fix the code, this check should only be done before expiry.
Rescuing funds
This bug meant that, after liquidity was withdrawn, option holders were unable to exercise their options and all funds were locked up in the contract. To fix this, admin methods had to be used to deposit liquidity back into the contract. Admin methods are methods that can only be called by the deployer and are intended to be used in emergency situations like this. After liquidity is deposited, totalSupply() would no longer be 0 and the sell() method could be called by option holders to collect their payoffs.
Firstly, the contracts were paused to prevent anyone from interacting with them during this process. Then, since deposit() can only be called after expiry, the expiry time was overridden to be in the future. Lastly, liquidity was deposited back into the contract and the expiry time was reset back to the original.
At this point, totalSupply() was larger than 0, so option holders would now be able to exercise successfully. However, to retrieve the funds that were just deposited, disputeExpiryPrice() was called, which resets a variable called poolValue that stores how much should be paid back to LPs. Then the treasury funds just deposited could successfully be withdrawn and the contacts safely unpaused. A small amount of liquidity was left in each contract so that sell() could still work.
Next steps
In order to mitigate the likelihood and impact of incidents like this, we plan to:
- Add emergencyWithdraw method to OptionMarket that allows the deployer to withdraw all funds at any time. This gives a lot of power to the deployer but increases the chance of recovering locked funds. This method will be removed in the future.
- Keep a small TVL cap on the contracts
- Set up a bug bounty program
- Fast-track our plans for a future audit