On Friday the 8th of February, at 5pm UTC the Oasis team initiated a contract migration process on eth2dai.com due a vulnerability found in the old contract. As part of the upgrade process, users were asked to cancel resting orders on the old contract and migrate to a new contract. The migration process was completed on Saturday, the 9th of February at 5pm UTC. In an abundance of caution and with the intention of prioritizing user security, we provided limited detail at the time.
This article provides additional detail about the vulnerability and describes briefly how the new Oasis contract matching engine works.
The Oasis smart contract (internally called MatchingMarket) maintains an on-chain order book, which matches orders and settles trades.
Oasis uses a fully-decentralised, on-chain architecture. It is a non-custodial marketplace which provides transparent, auditable, and a fully autonomous matching engine.
The contract internally maintains a list of orders sorted by price. This structure makes order matching easy as long as there are orders to match. Adding a new order is also straightforward—the contract method goes through the order list until it finds the correct position to add a new order. This guarantees that the order book is sorted.
If the matching engine is used (through the offer() function), it will always try to fill the order from the top of the order book. However, it is also possible to fill a specific order (through the buy() function) by providing an order ID as a parameter ( this is called “order cherry picking”).
It is important to note that the Oasis contract doesn’t check if the order book is correctly sorted by price after an order is partially filled. It simply assumes that the price of whatever is left of any partially filled order doesn’t change.
This assumption has proven to be false as the following vulnerability was found.
To fully understand the nature of this specific vulnerability, let’s have a look at how the MatchingMarket stores order information in its on-chain registry:
The order information comprises the token type and the amount a user wants to sell (pay_amt, pay_gem), the token type and the amount a user wants to buy, the address from the order creator and the timestamp which represents when an order was created.
Let’s use a real-world example from the end of January where an order with order ID 55525 got filled on the ETHDAI market. This order has a pay_amt value of 3787256913820008445 WETH and a buy_amt value of 408919634999999999959 DAI which results in a price of 107.9725 DAI per 1 WETH.To get a real representation of solidity internal values a division by 10^18 is necessary. So with the order 55525 somebody wanted to sell 3.787 WETH for 408.90 DAI.
When an order is partially filled, the buy(orderId, quantity) function calculates the token amount that needs to be paid for the quantity being bought as a simple proportion:
spend = mul(quantity, offer.buy_amt) / offer.pay_amt;
If someone fills half of the order 55525 (selling 1.89 WETH) the user will get 204.068043899999999965 DAI. As a result, an order with 1897256913820008445 ETH and 204851591099999999994 DAI with an implicit price (buy_amt/ pay_amt) of 107.9725 will remain as a resting order on the order book.
So far so good. But what if somebody fills the order 55525 with 408919634999999999958 DAI? In this case the spend amount (i.e. the amount of sold WETH) should be calculated as 3787256913820008444.9907383833162718917975662228006418 which will be truncated to 3787256913820008444 because in solidity integer divisions result into truncation. As a result, order 55525 will be left with 0.000000000000000001 DAI and 0.000000000000000001 WETH and an unexpected implicit price of of 1 per 1 WETH.
The implicit price and integer division problem showed that dust orders should not actually spam the order book.
The problem was recognized during the process of building the matching engine. Therefore, a dust limit for a given token type was added to ensure that the price of the order is correctly computed (up to the desired precision).
The offer() function, which creates and adds a new order to the order book, checks if the order amount is greater than the so-called dust limit for a given token type.
Unfortunately, this restriction was only imposed on the creation of a new order. A partial fill (99.999999999% of the order amount) could still result in a creation of a dust order with amounts small enough to severely distort the price of such an order.
If a dust order with a distorted price appeared at the top of the order book’s sell or buy side a malicious attacker could create a low priced order which the matching engine would consider as the best offer.
On Monday the 28th of January, at 12:45 UTC a user named monero_rs informed the Oasis team on reddit about, which appeared to look like a flash crash on oasisdex.com.
After analysis of the incident, the Oasis team found the vulnerability and prepared a fix two days later on Wednesday the 30th of January.
The Oasis team considers the specific sequence of events around order 55525 an accident which did not result as pre-planned attack of a malicious actor whose goal was to manipulate the order book with the vulnerability described above.
It is also worth noting that the user who actually sold Ether for the price of 70 DAI used a contract which did not specify a reasonable price limit that would have prevent the low priced order to be filled.
However, the fact that a malicious actor could attack the order book was reason enough to proceed with an emergency contract upgrade. The new version of the contract simply returns any remaining dust of the partial fill that is smaller than the dust limit to the order creator.
This contract migration is part of the ongoing and continuous cycle of monitoring, testing and upgrades that the Oasis team has in place to ensure we’re delivering the best possible solution for our users. We appreciate your patience and understanding as we moved from one contract to another.