Impact of the Istanbul hard-fork on a sports betting contract

Degens
7 min readJan 10, 2020

The Ethereum network recently executed a non-controversial hard-fork named Istanbul. This was a fairly minor upgrade and didn’t make any user-visible changes, however it did change the gas costs for various operations.

Degens is a sports betting exchange built on Ethereum. Since our users are, well, degens, they like to place a lot of bets. We’d like to keep the gas costs as low as possible. This post will look at how Istanbul affected our smart contract, and tries to make a case for a potential future enhancement for Ethereum (which would require a new hard-fork).

TLDR: Using the Degens contract is slightly cheaper thanks to Istanbul, and in certain cases is considerably cheaper. If you’re interested in the technical explanation of why that is, keep reading.

Calldata became cheaper

Before the fork, transaction senders had to pay 68 gas for every byte in their calldata (except for 0 bytes, which only cost 4 gas each). Calldata is used when invoking functions on smart contracts, and it contains all the parameters to the smart contract call. In Degens, this includes the hash of the match you are betting on, how much you’d like to bet, and the signed orders created by your betting partner(s).

After the Istanbul fork, each calldata byte now costs 16 gas (except for 0 bytes, which remain 4).

This means that when you make a trade against a single order, you pay about 9k less gas, and for every additional order you add to the calldata, you save an additional 6k (yay!).

Why are 0 bytes handled differently than non-0 bytes? This is explained in the Ethereum whitepaper:

we saw that most transaction data in contracts written by users was organized into a series of 32-byte arguments, most of which had many leading zero bytes, and given that such constructions seem inefficient but are actually efficient due to compression algorithms, we wanted to encourage their use in place of more complicated mechanisms which would try to tightly pack arguments

Since the Istanbul fork, 0 bytes have become proportionally more expensive. Previously they cost about 6% of a non-0 byte, but now they cost 25%, which may cause some contract designers to re-evaluate the benefits of “tightly packing” arguments. For the Degens contract, our execution-packed encoding and signature packing save gas since unpacking is really just shifting and bitwise-ANDing, which is inexpensive compared to calldata (both pre and post Istanbul).

SLOAD became more expensive

The Istanbul changes are not all sunshine and roses though. Certain contracts are more expensive to use, particularly if they do a lot of storage reads, because the SLOAD operation increased from 200 to 800 gas.

The Degens contract does about 20 SLOAD operations per trade, meaning you are charged about 12k more gas (boo!). This eats up all the savings from calldata, and then some.

Why do we need to do so many SLOADs? The contract needs to read the current positions for each party to the trade and the current token balances and approvals for each party, whether the match has been finalized, the current fill status of the order, and whether the order creator has issued a cancelAll. In addition, it also redundantly re-reads many of these same storage slots again in order to update them, because most of the updates are adding onto the previous values (with+= ).

In some cases these redundant loads could be avoided by caching the values in memory (like with previous positions, filledAmount), but in others they are unavoidable since the additional SLOADs happen in the token contracts, not in Degens. Even in the avoidable cases, caching these values in memory would complicate the code and, as a consequence, might introduce security vulnerabilities. At the end of this article we will try to make the case for an modification to Ethereum that would improve this situation.

A note about expensive SLOADs: Most contracts that implement EIP712 compute the EIP712_DOMAIN in the constructor by hashing address(this) etc, and then writing it to storage. We do not do this. If you read our smart contract source code, you’ll see that the source code includes the contract address as a hex literal, which is hashed into the domain at compile-time. This sometimes causes confusion, and we’ve even heard claims that it is impossible/will never work. Of course, contract addresses are deterministically generated from the deployer’s address and nonce so it’s easy to pre-compute them before deployment. But why did we choose to do it this somewhat convoluted way? Because, by embedding the pre-computed domain in the contract byte-code, we avoid performing an SLOAD operation for each EIP712 signature verification, which now saves 800 gas per transaction (or, naively, per trade).

SSTORE became cheaper (sometimes)

Considering the previous two changes, Istanbul is so far a net-negative for us. Fortunately, there is one other relevant update in Istanbul. Previously, each storage write operation would cost a significant amount of gas. It would either cost 20k (for changing a value from zero to non-zero) or 5k (for “re-setting” an already non-zero value). There is also a gas refund available for clearing storage, but we’ll ignore that for now.

Often contracts will write to the same storage slot multiple times within a single transaction. For example, consider a simple smart contract function that sends some amount of a token to 2 separate addresses. It will do this by calling transfer() for the first recipient, which decreases its balance storage slot in the token contract, and then calling transfer() again for the second recipient, which again decreases the same storage slot in the token contract.

Because storage writes are expensive, some contracts would cache values in memory and only write them out at the very end, so that they only execute a single SSTORE. Unfortunately, this complicates the smart contract code and thus can result in bugs (as was the case in Compound).

To improve the situation, Istanbul changes how SSTORE accounting works. Instead of each SSTORE operation being accounted for independently, the total number of storage slots modified in the transaction is tracked, and this determines the cost (actually each individual SSTORE does still cost 800 gas, but this is down from 5k). This discount is referred to as “net metering”. In other words, you’re only charged for the number of storage slots that are actually different at the end of the transaction from what they were at the beginning.

In the Degens contract, the simple case of two parties entering into a bet requires that tokens are moved by calling transferFrom() twice, once for each party. This results in two separate modifications to the smart contract’s balance inside the token contract’s storage. Because of net-metering, we save 5000 – 800=4200 gas (yay!). For a simple trade, this final reduction brings us about 1k gas under where we were pre-Istanbul.

But it gets better: As transactions get more complicated, the savings due to net-metering SSTORE increase. For example, consider this bet:

Trade against 2 orders

Because the first order wasn’t large enough to satisfy the desired bet size, the light green guy included 2 orders in the transaction, which resulted in 2 separate trades. This is depicted as 1 entry in the Degens activity history, but you can see that it is in fact 2 trades because there are two Blockie identicons on the right side of the handshake.

In this case, we pay only 5000 + 800*3 = 7400 gas to update the Degens balance whereas before it would cost 5000*4 = 20000, for a savings of 12600 gas. Not only that, but the light green guy’s position and token balance are also updated twice (one for each trade) which gives us another 8400 in savings. These savings scale as we include more orders, and fortunately they scale faster than the offsetting increase in SLOAD prices.

We get an even better savings when orders are matched by an arbitrageur. For example, take a look at the following:

Matching 2 orders

In the above, the running guy took two orders with opposite directions and overlapping odds and matched them together. The way this works under the hood is that the runner makes a trade against the first order, and then another against the second order. Because the first trade results in a position, and the second results in that position being sold off, not only do we get the benefits described previously, but we also put the runner’s position back to what it was before, which means we hardly pay anything for the intermediate position storage writes!

A case for net-metering of SLOAD

As described above, our contract redundantly re-reads storage locations. In some cases, we could cache these values at the expense of a more complicated smart contract. But in other cases we could not, either because the SLOAD is done by another contract, or because another contract (that we called) may have modified it since.

The phenomenon of “recently read data will probably be re-read soon” shows up often enough to have a name: temporal locality. Generally, it is optimized for by using a cache.

An improvement I think we could make to Ethereum would be to implement a sort of “net-metering” for SLOAD as well as SSTORE operations. In this case, transactions would be billed for the number of distinct storage locations they access, as opposed to the number of SLOAD operations they perform (though probably each SLOAD itself would have a minimal cost, as with SSTORE).

Although I haven’t examined the various EVM implementations closely, I believe it shouldn’t be difficult to cache values previously read in a transaction in case they are read again. Successive SLOAD operations of the same addresses should then be quite inexpensive, and gas prices could reflect that.

In the case of Degens, a net-metering of SLOAD operations would save about 6k gas per trade. I imagine many other contracts would benefit also.

--

--