Inline Assembly in Solidity: A Practical Starter’s Guide

Vedant Chainani
Lumos Labs
Published in
8 min readJul 27, 2023

Solidity, the popular programming language for Ethereum smart contracts, is powerful and user-friendly. But sometimes, you need to go beyond the surface and tap into the raw power of the EVM. That’s where Assembly comes in. Assembly is the low-level language that allows developers to dive deep into the inner workings of the Ethereum Virtual Machine (EVM) and fine-tune their smart contracts for maximum efficiency and performance. It’s like having a superpower that lets you optimize every line of code and squeeze out every drop of potential from your smart contracts.

But before getting into writing some Assembly we need to get some insights about how EVM works.

EVM and Opcodes

The Ethereum Virtual Machine (EVM) is like the beating heart of the Ethereum blockchain. It’s a powerful and decentralized computer that executes smart contracts, ensuring consistency and reliability across the network.

But how does it work?

When you compile a contract, you will obtain a bytecode, which is a long sequence of bytes, such as:

608060405234801561001....36f6c63430008070033ab

This bytecode represents a list of tiny instructions, each consisting of 1 byte, known as opcodes.

Opcodes are used to perform various operations, including arithmetic calculations, memory manipulation, control flow, and storage access.

In the given bytecode, for example, the first instruction is 60 (1 byte), which corresponds to the opcode PUSH1. As of writing this, there are 141 opcodes available in the Ethereum Virtual Machine (EVM). You can look up all of the EVM opcodes on evm.codes.

What is Assembly?

Assembly also known as “inline assembly” is a low-level language that allows us to access the Ethereum Virtual Machine (EVM) at a low level.

It’s like having a backstage pass to the inner workings of the EVM!

With assembly, we can write code that bypasses some of the safety features and checks of Solidity, giving us more control over our smart contracts.

When we write assembly in Solidity, we use a language called Yul. Yul is an intermediate language that can be compiled to bytecode for the EVM.

At any point when coding in solidity, we can use the assembly { } keyword to start writing inline assembly.

Now, let’s talk about the order of control levels. We start with Solidity, which provides a high-level approach to writing smart contracts. But if we want even more control, we can dive into Yul (assembly). Yul allows us to manipulate the EVM at an even lower level, giving us the ability to fine-tune our code and make it more efficient.

Solidity < Yul (assembly) < bytecode (opcodes)

And if we’re feeling adventurous, we can go beyond Yul and write raw bytecode for the EVM. This is like speaking the EVM’s language directly and doesn’t require a compiler. It’s like being a master of the EVM itself!

Writing Inline Solidity

Let’s say we have a simple Contract named Box. This contract allows you to store a value, change it, and retrieve it. Here’s a breakdown of the code:

pragma solidity ^0.8.14;

contract Box {
uint256 private _value;

event NewValue(uint256 newValue);

function store(uint256 newValue) public {
_value = newValue;
emit NewValue(newValue);
}

function retrieve() public view returns (uint256) {
return _value;
}
}

Now, let’s convert the given Solidity code into inline assembly. We’ll start with the retrieve function. In the original Solidity code, the retrieve function reads the value stored in _value from the contract’s storage and returns it. To achieve a similar result in assembly, we can use the sload opcode to read the value.

The sload opcode takes one input, which is the key of the storage slot. In this case, the _value variable is stored in slot #0. So, in assembly, we can write:

assembly {
let v := sload(0) // Read from slot #0
}

Now that we have the value, we need to return it. In assembly, we can use the return opcode to accomplish this. The return opcode takes two inputs: the offset, which is the location in memory where the value starts, and the size, which is the number of bytes to return.

However, the value v returned by sload is in the call stack, not in memory. So, we need to move it to memory first. For this, we can use the mstore opcode, which stores a value in memory. It takes two inputs:

1. offset which is the location (in memory array) where the value should be stored

2. value which is bytes to store (which is v for us).

Putting it all together, the assembly code becomes:

assembly {
let v := sload(0) // Read from slot #0
mstore(0x80, v) // Store v at position 0x80 in memory
return(0x80, 32) // Return 32 bytes (uint256)
}

That’s it! We have successfully converted the body of the retrieve function into assembly code.

📌 Note: You might be wondering why we specifically chose the position 0x80 in memory to store the value. This is because Solidity reserves the first four 32-byte slots (from 0x00 to 0x7f) for special purposes. So, free memory starts from 0x80. In our simple case, it’s fine to use 0x80 to store the new variable. However, for more complex operations, you would need to keep track of the pointer to free memory and manage it accordingly.

function retrieve() public view returns (uint256) {
assembly {
let v := sload(0)
mstore(0x80, v)
return(0x80, 32)
}
}

Now we move to store function, to store a variable we can use the sstore opcode. It takes in two inputs:

1. key a 32-byte key in storage

2. value value to be stored

So, in assembly, we can write:

assembly {
sstore(0, newValue) // store value at slot 0 of storage
}

Now that we have stored the value, we can proceed to emit an event using the log1 opcode. The log1 opcode requires three inputs:
1. offset: This represents the byte offset in the memory where the event data will be stored.

2. size: This specifies the size in bytes of the data to be copied.

3. topic: This is a 32-byte value that serves as a label or identifier for the event.

To ensure that the log1 opcode has the necessary offset in memory, we need to use the mstore opcode to store the value in memory. Here’s the updated code:

assembly {
sstore(0, newValue) // store value at slot 0 of storage
mstore(0x80, newValue) // store newValue at 0x80
}

To use the log1 opcode, we need to provide three inputs. The first input, offset, should be set to 0x80 since we stored the value using the mstore opcode. The second input, size, can be set to 0x20, which represents 32 bytes. Now, you might be wondering what is that third argument we passed into log1. It is topic — sort of a label for event, like the name — NewValue. The passed-in argument is nothing but the hash of the event signature:

bytes32(keccak256("NewValue(uint256)"))

// 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd

With these updates, our store function becomes:

function store(uint256 newValue) public {
assembly {
// store value at slot 0 of storage
sstore(0, newValue)

// emit event
mstore(0x80, newValue)
log1(0x80, 0x20, 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}

Finally, our Box contract looks like this now:

pragma solidity ^0.8.14;

contract Box {
uint256 public value;

function retrieve() public view returns(uint256) {
assembly {
let v := sload(0)
mstore(0x80, v)
return(0x80, 32)
}
}

function store(uint256 newValue) public {
assembly {
sstore(0, newValue)
mstore(0x80, newValue)
log1(0x80, 0x20, 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}
}

We can take up another contract to send ether to an address, here’s how it can be done.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;

contract MyContract {

address public owner = payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);

function sendETH(uint256 _amount) public payable {
require(msg.value >= _amount,"Not Enough ETH Sent");
bool success;
assembly {
let o := sload(0)
success := call(gas(), o, _amount, 0, 0, 0, 0)
}
require(success, "Failed to send ETH");
}
}

Let’s break down the assembly code step by step:

1. First, the owner address is stored in storage slot 0 and assigned to the local variable o. The `sload` opcode is used to read a value from storage.

2. The next line executes the `call` opcode, which is used to send ether to an address. The call opcode takes several arguments:

  • gas: The gas() function returns the remaining gas for the current execution context. In this case, it is passed as the first argument to call, indicating that the maximum amount of gas should be provided for the function call.
  • address: The address of the contract/user to call. It is the value loaded from storage slot 0.
  • value: The amount of Ether (in wei) to send along with the function call. In this case, it is passed as the second argument to call.
  • The next four arguments (0, 0, 0, 0) are used for passing additional data to the function being called. In this code snippet, they are set to zero, indicating that no additional data is being passed.
  • The result of the call opcode is assigned to the local variable success. It will be true if the function call was successful, and false otherwise.

Limitations

When it comes to Solidity smart contracts, there is a compromise to be made between readability and efficiency. While many front-end developers can easily understand the functions executed in a Solidity smart contract and incorporate them into their web3 queries, assembly code can be intimidating for those who are not familiar with low-level programming.

Assembly code in Solidity can appear daunting and difficult to comprehend due to its low-level nature. The logic and flow of the code may not be immediately apparent to those who are not accustomed to working with assembly. However, despite its initial complexity, the use of assembly in Solidity can offer significant benefits, such as improved gas efficiency and competitive advantage.

One example of the use of assembly in Solidity is the ability to return a contract name. While the code may initially appear convoluted, with a named variable represented as unreadable hex code returned via assembly.

function _name() internal pure override returns (string memory) {
// Return the name of the contract.
assembly {
mstore(0x20, 0x20)
mstore(0x47, 0x07536561706f7274)
return(0x20, 0x60)
}
}

This function is used by the OpenSea Seaport contract.

It can be quite daunting at first but by leveraging assembly throughout a codebase, developers can optimize their smart contracts to consume less gas, resulting in cost savings for users. This gas efficiency can provide a competitive edge, particularly in platforms like OpenSea, where transaction costs can significantly impact user experience and adoption.

Ready to BUIDL?

In conclusion, the use of assembly in Solidity can be both a burden and a competitive advantage, depending on the context and the expertise of the development team. While assembly code may initially appear unattractive and difficult to understand, it can offer significant benefits in terms of gas efficiency and cost savings.

However, it is crucial for developers to carefully consider the trade-offs and assess whether the complexity of assembly code is worth the potential gains in their specific use case.

--

--