Advanced Solidity: Understanding and Optimizing Gas Costs (course notes)

image

These are my notes from the Udemy course:

How is the transaction fee of an Ethereum Transfer calculated?

The transaction fee is the product of the gas used by the transaction times the gas price. Gas price is usually on the order of billionths of an ether, so they use gwei (one billionth of an ether).

image

So the tx fee in the above is calculated via 21000 GAS * 48.34 gwei / 1 billion = 0.00101

Current gas price can be seen for example on GasTracker.

Transferring ether is the cheapest thing you can do on Ethereum (21000 GAS).

Why might Gas vary from transaction to transaction even if the same functions are called twice?

When conducting a transaction on ethereum, the same function on the same smart contract, the exact computation that is carried out is not identical all of the time.

What is the block limit in Ethereum?

In bitcoin, historically the block size was set to 1MB. In ethereum, there is not an explicit byte limit. Instead, there is a gas cost limit. Can only have so much computation per block. If the block gas limit is too high, the nodes verifying the transaction might get stuck processing too many transactions. The gas block limit is 30 million GAS.

What is a storage slot in Ethereum?

The Ethereum Virtual Machine knows what variable you are referencing by referencing the storage slot. The storage slot is the address or location in storage. You could get the slot for a given variable by running:

uint256 private a;
uint256 private b;
getStorageLocation() {
  uint256 slotLocation;

	assembly {
    slotLocation := a.slot
  }

  return slotLocation 
}

In the above, the assembly directive lets you write close to opcode level code. The storage slots are incremented starting from 0 by default (b would be at storage slot 1)

What are opcodes in Ethereum?

Opcodes or machine codes in Solidity/Ethereum are the same concept for other computers. To add a variable x + 1 (stored at slot 0) would have opcodes:

PUSH 0
LOAD
PUSH 1
ADD

The ethereum yellow paper has a reference to all opcodes:

A significant cost of the transaction is the sum of opcodes in Ethereum. Each opcode costs some gas. The gas costs per opcode are defined in the spec.

Each opcode maps to a specific stack number which is a hexidecimal number. For example, the stack number 0x60 represents PUSH1. So in the below, the first two instructions are:

PUSH 80
PUSH 40
image

The more opcodes, the more bytecode, the higher the gas cost.

What does the unchecked directive do in Solidity?

The unchecked directive allows executing solidity that is not underflow / overflow protected.

What does an sload opcode do in Solidity?

The sload opcode reads a (u)int256 from storage.

How are function names stored in the bytecode?

Solidity does not store function names in the bytecode. What it does store is the keccak256 of the function signature. For example:

{
  "ed18f0a7": "blue()",
	"b900f571": "gray(uint256,address)"
}

How can you execute a function on a contract where the source code hasn’t been published?

In an ethereum transaction, the “input data” is controls what function is executed on a smart contract. So for example, if you have a contract like the above (which calls blue() , you could execute it by sending a transaction in Metamask and inputting in the Hex Data field the selector ed18f0a7. (In solidity this shows up in message.data)This would execute that function. The minimum size of data is four bytes (8 characters) as this is the function selector size.

image

Why is one byte of input data equal to two characters in solidity?

Input data is sent as hex. One byte in binary is in the range 00000000 - 11111111 (0-255 in decimal). This is equivalent to 0x00-0xff in hex.

How much does input data effect gas costs?

Any transaction data on ethereum is stored permanently on the blockchain. Storing this data has a non-zero cost. This cost is specified in the ethereum yellow paper.

image

So in the above example, we’re sending 8 HEX values. Each byte is two hex characters so we’re sending 4 bytes. 4 bytes * 16 transaction data = 64 GAS.

How does memory work in solidity?

“Storage” is reading from a harder data storage layer. Memory is anything that is added to working memory for a function. It’s indexed like an array and can only write in 32 bye increments. This is the same kind of memory as in RAM, it only stays around while the function is being executed.

image

The first 128 bytes in memory are reserved for things like scratch space for hashing methods, currently allocated memory size, and the “zero slot”. In order to do this, the first few opcodes will always look like:

PUSH 80
PUSH 40
MSTORE

What this is doing is an mstore 0x80 0x40 which if converted from hex to decimal is mstore 128 64 which means it’s storing the value 128 in the 64th-byte to the 96th byte. It’s doing this to fulfill the below solidity specification - basically saying we’ve currently allocated 128 bytes.

image

MSTORE costs 3 gas for every 32 bytes allocated even if you don’t touch the earlier slots, it’s the highest slot that is stored to.

Why is a “payable” function cheaper than a “nonpayable” function?

A nonpayable function will check if the transaction has non-zero value attached to it and revert if it does. This makes it slightly cheaper (additional 16 GAS to check this value).

Why would you use an unchecked directive?

Within an unchecked directive, if a number overflows it will flip around (go from max value, 255 for a uint8, to 0). It will save a bit of gas though if we use the unchecked directive, so if we can guarantee the value is never overflow/underflow it will be a bit cheaper to use.

What is the 21000 minimum gas number for a transaction?

There are some checks that are required to ensure a given transaction is valid (e.g. checking the transaction signature is valid, that the transaction is well formed, that the nonce matches the sender’s current nonce, etc). This results in 21000 gas minimum.

What is the Gas Limit?

Every transaction has a specific amount of gas associated with it - the gas limit. This is the amount of gas which is implicitly purchased from the sender’s account balance. If the account balance can’t purchase the amount of gas the transaction is invalid. It’s called the gas limit because any unused gas is refunded after the transaction.

What is the nonce used for in a transaction?

The current nonce is used to ensure a transaction is not duplicated. The current nonce reflects the # of transactions a account has sent and is incremented on each transaction.

What does the optimizer do?

The optimizer reduces the opcodes used during execution. A common misconception is that it reduces opcodes used for deploy. It doesn’t do that and deploy cost could go up with the optimizer.

What are the gas costs associated with Storage?

Storage opcode (SSTORE) is pretty expensive (20000 GAS). Reading a variable storage for the first time in a transaction costs 2100 gas.

image

Doing a read of a storage variable already in memory is much cheaper - about 100 gas. So doing a storage read and then a storage write is similar to doing just a storage write. Cost(read + write) is roughly equal to Cost(write).

Regardless of the size of the integer (e.g. uint8 vs uint256) the gas costs are the same because the storage slots are 32 byte slots regardless of if the variable can take up the full size.

Assigning a variable to the same value it already is costs 100 gas. Can be an optimization to avoid assigning a variable to an unchanged value.

How does gas work when assigning array values?

Each array element generally incurs a lookup and a storage fee. Can treat the array elements as if they are separate individual variables. The length of the array is a separate variable that is stored and updated as the array changes.

How does gas refunds work when setting an item from nonzero to zero?

The maximum gas refund that you can receive is 1/5 the cost of the transaction. When you set a value from nonzero to zero, that’s one less thing that ethereum has to track so you get a max refund of 5000 for doing so.

When testing gas cost of a function, it’s important to be using values that will test setting variables from (zero,nonzero)→(zero,nonzero). Gas costs can vary significantly by values.

How are strings stored in solidity?

Strings are stored as arrays in solidity. If a string length is less than 32 bytes, it can be stored in one storage slot. As the string length increases it uses more slots it increases the cost of storage.

What is variable packing in solidity?

On top of “slots” it’s solidity / the evm will optimize storage space by combining slots if there is enough space in one slot to store more than one variable. For example two uint128’s could be stored in one storage slot together. The way this works is there is an “offset” stored alongside the slot which denotes where in the slot the value applies to.

For example, one slot with two uint128 variables (assigned value 2 and 1) packed together would look like the below, with offset 0 and offset 16:

000…002000…001

This actually makes smaller integers in general more expensive. Where smaller integers or intentional variable packing makes sense is if you are going to be updating multiple variables in the same slot in the same transaction. Packing multiple variables together could be dramatically cheaper this way.

What is the difference between memory vs calldata in input variables?

Calldata is the data that was sent into the function transaction and cannot be manipulated. It’s usually cheaper to use calldata. However if you want to manipulate the calldata (say, change the first element of an array in input data), then it would be slightly cheaper to have already copied the value into memory.

Solidity Gas Saving Tricks

In general, use cheaper opcodes where possible or try to create semantically identical things that use cheaper opcodes.

  • Function name can affect gas cost
    • The function names are sorted in hexidecimal (the function selector) order. When invoking a function, the opcodes check in that sorted order which function to invoke based on the transaction data. So if there is a more gas sensitive function to call, can save some gas by making sure it is sorted first.
  • Less than is cheaper than less than or equal to
    • There is only a LT or GT opcode, there is no LTE or GTE opcode. So when using the ≤ or ≥ operator in solidity it generates two opcodes (tiny bit more gas).
  • Bit shifting is slightly cheaper than multiply by 2 / divide by 2.
    • opcode for multiplication is 5 gas, opcode for shifting is 3 gas.
  • Revert early
    • Gas paid in a transaction that is reverted is the gas cost up until the reversion, nothing after. Gas that is used is gone even though the revert undoes any setting of storage variables up until that point.