In-Depth Guide on How to Write an ERC20 Token Contract in Yul

Pari Tomar
Sphere Audits
Published in
22 min readMar 22, 2024

--

In this article, we will explore how to write an ERC20 token contract using Yul, an intermediate language that provides direct interaction with the Ethereum Virtual Machine (EVM).

The need for this guide arises from the complexities of optimizing smart contracts for performance and security while adhering to the ERC20 standard. Yul offers a path to overcome these challenges by enabling lower-level control over contract code, leading to more efficient and secure smart contract deployments.

We will solve these challenges by detailing the process of:

  • Structuring ERC20 functions in Yul for effective token management
  • Implementing security measures against vulnerabilities
  • Optimizing gas usage to minimize transaction costs

By the conclusion, readers will possess a comprehensive understanding of ERC20 contract development in Yul. Before we start, make sure you go through the comprehensive guide to Yul.

Introduction to ERC20 and Yul

What is ERC20?

Imagine you want to create your own type of digital money that you can trade, share, or even use in online games and applications. ERC20 is essentially a set of rules that helps you make this digital money in a way that works smoothly on the Ethereum network. It’s like a recipe that ensures your digital coins can easily be exchanged and used by others.

Here’s what it covers:

  • Making and Tracking Tokens: It tells you how to create new tokens/coins, how many token you can make, and keeps track of who owns how many.
  • Sending Tokens: It shows you how to transfer tokens from one person to another safely.
  • Using Tokens with Permission: It allows token owners to let others spend a certain amount of their tokens, useful for automated services or trading.

What is Yul?

Yul is like a secret code language for talking directly to the EVM. When people create smart contracts, they usually write them in a language called Solidity, which is easier to understand and use. But sometimes, developers need to be very specific about how they tell Ethereum to do things, especially if they want to save gas on transaction fees or do something very custom.

Thats where Yul comes in. Think of Yul as being closer to the machine’s language, allowing developers to give more precise and direct instructions.

Yul lets developers:

  • Control Details: They can manage small details of how their contract works, which is harder to do in Solidity
  • Save Gas: By being more direct, they can make their contracts use less gas
  • Do Advanced Tricks: For very specialised tasks, Yul allows developers to write code that’s more flexible and powerful.

Now that we’ve got a basic understanding of ERC20 and Yul, let’s dive into creating our smart contract with Yul.

Setting Up Your Development Environment

Getting ready to write your ERC20 contract in Yul is straightforward. Follow these steps to set up:

  • Open your web browser and go to the Remix IDE website. Remix IDE is an online tool for writing, testing and deploying Ethereum contracts.
  • Once in Remix, you’ll start in the default workspace. This area lets you organize your work with folders like contracts for your contract files, scripts for deployment scripts, and tests for your test files.
  • Navigate to the contracts folder and create a new file named ERC20Yul.sol. This file will contain your Yul code for the ERC20 token.
  • With your ERC20Yul.sol file created, you're all set to begin crafting your smart contract in Yul.

Writing the ERC20 Contract in Yul

First up, we’re going to lay down the foundation for our smart contract and set up all the variables we’ll be using.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract ERC20Yul { }

Setting Up Variables and Constants

bytes32 internal constant _TRANSFER_HASH =
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef;
bytes32 internal constant _APPROVAL_HASH =
0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925;
bytes32 internal constant _INSUFFICIENT_BALANCE_SELECTOR =
0xf4d678b800000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _INSUFFICIENT_ALLOWANCE_SELECTOR =
0x13be252b00000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _RECIPIENT_ZERO_SELECTOR =
0x4c131ee600000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _INVALID_SIG_SELECTOR =
0x8baa579f00000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _EXPIRED_SELECTOR =
0x203d82d800000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _STRING_TOO_LONG_SELECTOR =
0xb11b2ad800000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _OVERFLOW_SELECTOR =
0x35278d1200000000000000000000000000000000000000000000000000000000;
bytes32 internal constant _EIP712_DOMAIN_PREFIX_HASH =
0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;
bytes32 internal constant _PERMIT_HASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
bytes32 internal constant _VERSION_1_HASH =
0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6;
bytes32 internal constant _MAX =
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
bytes32 internal immutable _name;
bytes32 internal immutable _symbol;
uint256 internal immutable _nameLen;
uint256 internal immutable _symbolLen;
uint256 internal immutable _initialChainId;
bytes32 internal immutable _initialDomainSeparator;
mapping(address => uint256) internal _balances;
mapping(address => mapping(address => uint256)) internal _allowances;
uint256 internal _supply;
mapping(address => uint256) internal _nonces;
event Transfer(address indexed src, address indexed dst, uint256 amount);
event Approval(address indexed src, address indexed dst, uint256 amount);
  • _TRANSFER_HASH, _APPROVAL_HASH, and Similar Constants:

These constants are precomputed hashes of specific strings, often event signatures or function selectors. They are used within inline assembly blocks to optimise gas usage by avoiding runtime computation of these hashes.

  • _name and _symbol:

These immutable variables store the token’s name and symbol, respectively, in a fixed size bytes32 format. They are set during contract deployment and are designed to store and access these atrributes without the need for dynamic string storage.

  • _nameLen and _symbolLen :

These immutable variables capture the length of the token’s name and symbol. They are necessary because the name and symbol are stored as bytes32, and knowing their actual lengths is required to correctly convert them back to strings when needed.

  • _initialChainId and _initialDomainSeparator :
  • _initialChainId stores the chain ID at the time of contract deployment, used in domain separation for EIP-2612 permits to prevent replay attacks across different chains.
  • _initialDomainSeparator is a precomputed EIP-712 domain separator based on the initial chain ID, again used in the context of EIP-2612 permits.
  • _balances and _allowances:
  • _balances is a mapping that tracks each address’s token balance, a fundamental part of any ERC20 token
  • _allowances is a mapping of mappings that tracks how much one address is allowed to spend on behalf of another address, essential for the approve and transferFrom functions.
  • _supply

This variable keeps track of the token’s total supply, updated as tokens are minted or burned.

  • _nonces:

Used for EIP-2612 permit functionality, this mapping tracks nonces for each address to ensure each permit call is unique and prevent replay attacks.

  • Event Declarations (Transfer and Approval):

These events are declared to notify external subscribers of token transfers and approvals, which are crucial for ERC20 token’s usability.

Implementing the Constructor

constructor(string memory name_, string memory symbol_) {
// get string lengths
bytes memory nameB = bytes(name_);
bytes memory symbolB = bytes(symbol_);
uint256 nameLen = nameB.length;
uint256 symbolLen = symbolB.length;
// check strings are <=32 bytes
assembly {
if or(lt(0x20, nameLen), lt(0x20, symbolLen)) {
mstore(0x00, _STRING_TOO_LONG_SELECTOR)
revert(0x00, 0x04)
}
}
// compute domain separator
bytes32 initialDomainSeparator = _computeDomainSeparator(
keccak256(nameB)
);
// set immutables
_name = bytes32(nameB);
_symbol = bytes32(symbolB);
_nameLen = nameLen;
_symbolLen = symbolLen;
_initialChainId = block.chainid;
_initialDomainSeparator = initialDomainSeparator;
}

Converts the name_ and symbol_ parameters from string to bytes to get their lengths.

Verifies that both the name and symbol are within the 32-byte limit. This limit is due to the storage if these parameters in bytes32 variables, optimizing gas costs by avoiding dynamic storage. If either exceeds this limit, the contract reverts with a custom error.

  • Calls _computeDomainSeparator with the hash of the name_ parameter. This function calculates the EIP-712 domain separator, which is crucial for securely implementing EIP-2612’s permit feature. The domain separator helps ensure that signed messages intended for permit functionality are specific to this contract and chain, guarding against replay attacks.
  • Initialize the _name, _symbol, _nameLen, _symbolLen, _initialChainId, and _initialDomainSeparator variables.

The name and symbol are stored in bytes32 format, directly from the input parameters, ensuring efficient storage and access. The lengths of name and symbol are stored to facilitate conversion back to strings when needed.

Stores the chain ID at deployment time in _initialChainId to support the domain separator’s chain specificity.

Sets _initialDomainSeparator with the precomputed value for use in permit functionality.

Transfer Function

function transfer(address dst, uint256 amount)
public
virtual
returns (bool success)
{
assembly {
// Check if the destination address is not zero.
if iszero(dst) {
mstore(0x00, _RECIPIENT_ZERO_SELECTOR)
revert(0x00, 0x04)
}
// Load the sender's balance, check for sufficient balance, and update it.
mstore(0x00, caller())
mstore(0x20, 0x00)
let srcSlot := keccak256(0x00, 0x40)
let srcBalance := sload(srcSlot)
if lt(srcBalance, amount) {
mstore(0x00, _INSUFFICIENT_BALANCE_SELECTOR)
revert(0x00, 0x04)
}
sstore(srcSlot, sub(srcBalance, amount))
// Update the destination's balance.
mstore(0x00, dst)
let dstSlot := keccak256(0x00, 0x40)
sstore(dstSlot, add(sload(dstSlot), amount))
// Log the Transfer event.
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, caller(), dst)
// Indicate successful execution.
success := 0x01
}
}
  • The function first checks if the destination address dst is not the zero address. Transfer to the zero address are generally considered a burn operation and are not allowed in this implementation to prevent accidental loss of tokens.
  • It retrieves the balance of the sender msg.sender from the _balances mapping using inline assembly for efficiency and direct access to storage. If the sender does not have enough tokens (amount requested for transfer is greater than the sender’s balance), the transaction is reverted with an “insufficient balance” error. The sender’s balance is decreased, and the recipient’s balance is increased by the amount.
  • A Transfer event is emitted with the sender’s address, recipient’s address, and the amount transferred.
  • The function returns true (success := 0x01) to indicate successful execution. This return pattern follows the ERC20 standard, providing a boolean response to the caller.

TransferFrom Function

function transferFrom(
address src,
address dst,
uint256 amount
) public virtual returns (bool success) {
assembly {
// Check if the destination address is not zero.
if iszero(dst) {
mstore(0x00, _RECIPIENT_ZERO_SELECTOR)
revert(0x00, 0x04)
}
// Calculate allowance mapping storage slot and load the allowance value.
mstore(0x00, src)
mstore(0x20, 0x01)
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, caller())
let allowanceSlot := keccak256(0x00, 0x40)
let allowanceVal := sload(allowanceSlot)
// Check if allowance is sufficient and not infinite (_MAX).
if lt(allowanceVal, _MAX) {
if lt(allowanceVal, amount) {
mstore(0x00, _INSUFFICIENT_ALLOWANCE_SELECTOR)
revert(0x00, 0x04)
}
// Update the allowance if it's not set to _MAX.
sstore(allowanceSlot, sub(allowanceVal, amount))
}
// Load the sender's balance, check for sufficient balance, and update it.
mstore(0x00, src)
mstore(0x20, 0x00)
let srcSlot := keccak256(0x00, 0x40)
let srcBalance := sload(srcSlot)
if lt(srcBalance, amount) {
mstore(0x00, _INSUFFICIENT_BALANCE_SELECTOR)
revert(0x00, 0x04)
}
// Deduct the amount from the sender's balance.
sstore(srcSlot, sub(srcBalance, amount))
// Update the destination's balance.
mstore(0x00, dst)
let dstSlot := keccak256(0x00, 0x40)
sstore(dstSlot, add(sload(dstSlot), amount))
// Log the Transfer event.
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, src, dst)
// Indicate successful execution.
success := 0x01
}
}
  • Similar to transfer, it checks that the destination address dst is not zero to prevent tokens from being burned unintentionally.
  • Retrieves the allowance set by the token owner (src) for the caller and ensures it is sufficient for the requested amount. If the allowance is not enough, the fnction reverts with an “Insufficient Allowance” error. If the allowance is not the special case of _MAX, it deducts the transfered amount from the allowance.
  • Checks that the token owner src has enough balance to cover the amount being transferred. If not, it reverts with an “Insufficient Balance” error. Deducts the amount from the token owner’s balance and adds it to the recipient’s dst balance.
  • Emit a Transfer event with the source address, destination address, and the amount transferred,
  • Returns true (success := 0x0) upon successful execution, following the ERC20 standard;s convention for indicating success.

Approve Function

function approve(address dst, uint256 amount)
public
virtual
returns (bool success)
{
assembly {
// _allowances[msg.sender][dst] = amount;
mstore(0x00, caller())
mstore(0x20, 0x01)
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, dst)
sstore(keccak256(0x00, 0x40), amount)
// emit Approval(msg.sender, dst, amount);
mstore(0x00, amount)
log3(0x00, 0x20, _APPROVAL_HASH, caller(), dst)
// return true;
success := 0x01
}
}
  • The function sets the allowance for dst (the spender) to amount, allowing dst to spend up to amount of tokens on behalf of msg.sender (owner). This operation directly manipulates sotrage using inline assembly for calculating the storage slot of the allowance based on the addresses of the token owner and spender.
  • It emits an Approval event with the owner’s address (msg.sender), the spender’s address dst, and the amount of the allowance.
  • The function returns true to indicate successful execution. This boolean return is standard across ERC20 approve functions, allowing calling contracts and transactions to verify that the approval was successful.

Permit Function

function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
assembly {
// Check if the deadline has already passed.
if gt(timestamp(), deadline) {
mstore(0x00, _EXPIRED_SELECTOR)
revert(0x00, 0x04)
}
// Compute the domain separator and the digest for signing.
let separator := DOMAIN_SEPARATOR()
let nonce := sload(add(_nonces.slot, owner))
let digest := keccak256(abi.encodePacked("\\x19\\x01", separator, keccak256(abi.encode(_PERMIT_HASH, owner, spender, value, nonce, deadline))))
// Recover the signer from the signature.
let recovered := ecrecover(digest, v, r, s)
// Check if the recovered address is not zero and matches the owner.
if or(iszero(recovered), iszero(eq(recovered, owner))) {
mstore(0x00, _INVALID_SIG_SELECTOR)
revert(0x00, 0x04)
}
// Increment the nonce for the owner to prevent replay attacks.
sstore(add(_nonces.slot, owner), add(nonce, 1))
// Approve the spender to spend the specified value.
mstore(0x00, owner)
mstore(0x20, spender)
let allowanceSlot := keccak256(0x00, 0x40)
sstore(allowanceSlot, value)
// Emit an Approval event.
log3(0x00, 0x20, _APPROVAL_HASH, owner, spender, value)
}
}
  • Checks that the current timestamp is before the specified deadline. This ensure the permit is not used after it has expired.
  • Calculates the EIP-712 digest using the domain separator, the permit parameters (owner, spender, value), the owner’s current nonce, and the deadline.
  • Uses ecrecover to recover the address from the signature components (v, r, s) and the digest. This recovered adddress miust match the specified owner address, ensuring that the signature is valid and indeed from the owner.
  • Increments the owner’s nonce to ensure that each permit can only be usd once.
  • Sets the allowance for the spender to spend value tokens on behalf of the owner. This is the core functionality of the permit , equivalent to calling the approve function but wihtout requiring a transaction from the owner.
  • Emits a Approval event indicating the new allowance set by the permit.

Allowance Function

function allowance(address src, address dst)
public
view
virtual
returns (uint256 amount)
{
assembly {
// Calculate the storage slot for the allowance mapping using src and dst.
mstore(0x00, src)
mstore(0x20, 0x01) // The slot for _allowances mapping.
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, dst)
let allowanceSlot := keccak256(0x00, 0x40)
// Load the allowance amount from the calculated storage slot.
amount := sload(allowanceSlot)
}
}

The function calculates the exact storage slot where the allowance information is stored. ERC20 allowances are kept in a nest mapping mapping(owner => mapping(spender => uint256), so the calculation involves 2 steps:

  • First, it finds the slot corresponding to the owner in the _allowances mapping.
  • Then, it calculates the slot for the spender within that owner;s mapping. This is done using the keccak256 hash function, a standard approach for nested mapping slot calculationg in solidity.

After determining the correct storage slot, the function reads the allowance amount directly from EVM storage using the sload operation.

Returns the allowance amount, indicating how many tokens the spneder is allowed to transfer on behalf of the owner.

balanceOf Function

function balanceOf(address src)
public
view
virtual
returns (uint256 amount)
{
assembly {
// Calculate the storage slot for the balance mapping using src.
mstore(0x00, src)
mstore(0x20, 0x00) // The slot for _balances mapping.
let balanceSlot := keccak256(0x00, 0x40)
// Load the balance amount from the calculated storage slot.
amount := sload(balanceSlot)
}
}

Determines the precise storage slot where the balance information for the given address (src) is stored. In Solidity, the balance of each account is kept in a mapping (mapping(address => uint256) _balances), and the slot for each address in this mapping is computed using a hash function.

The function first stores the address (src) in memory and then uses the keccak256 hash function, combining the address with the slot number of the _balances mapping, to calculate the exact storage slot.

With the storage slot identified, the function reads the token balance directly from the Ethereum Virtual Machine (EVM) storage using the sload instruction.

Returns the queried balance (amount), indicating how many tokens are held by the src address.

nonces function

function nonces(address src) public view virtual returns (uint256 nonce) {
assembly {
// Calculate the storage slot for the nonce mapping using src.
mstore(0x00, src)
mstore(0x20, 0x03) // The slot for _nonces mapping.
let nonceSlot := keccak256(0x00, 0x40)
// Load the nonce value from the calculated storage slot.
nonce := sload(nonceSlot)
}
}

The function computes the storage slot where the nonce for a given address (src) is stored. Nonces are kept in a mapping (mapping(address => uint256) _nonces), where each address has an associated nonce that is incremented with each successful permit operation.

It uses the keccak256 hash function to calculate the exact storage slot for the src address's nonce. This calculation involves combining the address with the predetermined slot number of the _nonces mapping.

Retrieves the nonce value directly from EVM storage using the sload instruction. This step efficiently fetches the current nonce for the specified address, representing the count of permits issued by this address.

Returns the nonce value, which is essential for constructing valid permit requests.

totalSupply function

function totalSupply() public view virtual returns (uint256 amount) {
assembly {
// Load the total supply value from its storage slot.
amount := sload(0x02)
}
}

The function directly accesses the value of the total token supply from the Ethereum Virtual Machine (EVM) storage. The total supply is stored at a predetermined storage slot (0x02), following the contract's storage layout.

Returns the total supply of tokens (amount).

name function

function name() public view virtual returns (string memory value) {
bytes32 myName = _name;
uint256 myNameLen = _nameLen;
assembly {
// Allocate memory for the return string.
value := mload(0x40)
// Set the length of the string.
mstore(value, myNameLen)
// Store the name bytes directly after the length prefix.
mstore(add(value, 0x20), myName)
// Update the free memory pointer to avoid overwriting this string.
mstore(0x40, add(value, 0x40))
}
}

The function begins by allocating memory for the return value. It uses mload(0x40) to find the current "free memory pointer," which indicates where new data can safely be stored in memory.

The length of the token name (myNameLen) is stored at the beginning of the allocated memory.

The actual bytes of the token name (myName) are stored directly after the length prefix. Since the name is stored as a bytes32 type, it can be efficiently copied into memory in a single operation.

Finally, the “free memory pointer” is updated by adding 64 bytes to its previous value: 32 bytes for the length prefix and 32 bytes for the actual name data.

symbol Function

function symbol() public view virtual returns (string memory value) {
bytes32 mySymbol = _symbol;
uint256 mySymbolLen = _symbolLen;
assembly {
// Allocate memory for the return string.
value := mload(0x40)
// Set the length of the string.
mstore(value, mySymbolLen)
// Store the symbol bytes directly after the length prefix.
mstore(add(value, 0x20), mySymbol)
// Update the free memory pointer to avoid overwriting this string.
mstore(0x40, add(value, 0x40))
}
}

Similar to the name function, it starts by finding the current free memory pointer to determine where to safely allocate memory for the return string.

It stores the length of the token symbol at the beginning of the allocated memory space.

The bytes of the token symbol are stored in memory immediately following the length prefix. Ensures that the next data allocated in memory does not overwrite the symbol string by updating the free memory pointer accordingly. The pointer is moved forward by the size of the allocated string plus its length prefix.

DOMAIN_SEPARATOR function

// solhint-disable-next-line func-name-mixedcase
function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
return
block.chainid == _initialChainId
? _initialDomainSeparator
: _computeDomainSeparator(keccak256(abi.encode(_name)));
}

The function first checks if the current chainid matches the _initialChainId, which was stored when the contract was deployed. This is important because the domain separator includes the chain ID to ensure that signatures cannot be replayed on different networks.

If the chain ID hasn’t changed since deployment, the function returns _initialDomainSeparator. This value is computed and stored during contract initialization to save gas by avoiding recomputation in every call.

If the chain ID has changed (which could happen after a hard fork or if the contract is deployed on a different network), the function computes a new domain separator using _computeDomainSeparator. This method involves hashing together several pieces of data, including the contract's name (hashed), the current chain ID, and other constants defined by EIP-712 to form a unique identifier for signing contexts.

decimals fucntion

function decimals() public pure virtual returns (uint8) {
return 18;
}

The function simply returns a constant value, which is conventionally set to 18 for many ERC20 tokens, mirroring the divisibility of Ethereum’s native currency, Ether.

_mint function

function _mint(address dst, uint256 amount) internal virtual {
assembly {
// Check if the destination address is not zero to prevent burning tokens.
if iszero(dst) {
mstore(0x00, _RECIPIENT_ZERO_SELECTOR)
revert(0x00, 0x04)
}
// Load the current total supply and add the amount to mint.
let supply := sload(_supply.slot)
let newSupply := add(supply, amount)
// Check for overflow to ensure safe addition.
if lt(newSupply, supply) {
mstore(0x00, _OVERFLOW_SELECTOR)
revert(0x00, 0x04)
}
// Update the total supply with the new value.
sstore(_supply.slot, newSupply)
// Update the balance of the destination address.
mstore(0x00, dst)
mstore(0x20, _balances.slot)
let dstSlot := keccak256(0x00, 0x40)
let dstBalance := sload(dstSlot)
let newDstBalance := add(dstBalance, amount)
// Check for overflow to ensure safe addition to the destination balance.
if lt(newDstBalance, dstBalance) {
mstore(0x00, _OVERFLOW_SELECTOR)
revert(0x00, 0x04)
}
// Store the updated balance for the destination address.
sstore(dstSlot, newDstBalance)
// Emit a Transfer event from the zero address to indicate tokens were minted.
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, 0x00, dst, amount)
}
}
  • Ensures the destination address (dst) is not the zero address to prevent tokens from being mistakenly burned during the minting process.
  • Retrieves the current total supply of tokens, adds the minting amount to it, and checks for overflow to ensure the operation’s safety. The new total supply is then stored back in the contract’s state.
  • Calculates the storage slot for the recipient’s balance using the destination address and updates their balance with the minted amount, again checking for overflow to ensure the addition is safe.
  • Emits a Transfer event with the zero address as the sender to signify that tokens have been minted, following the ERC20 standard's convention for minting events.

_burn function

function _burn(address src, uint256 amount) internal virtual {
assembly {
// Check the balance of the source address to ensure it has enough tokens to burn.
mstore(0x00, src)
mstore(0x20, _balances.slot)
let srcSlot := keccak256(0x00, 0x40)
let srcBalance := sload(srcSlot)
if lt(srcBalance, amount) {
mstore(0x00, _INSUFFICIENT_BALANCE_SELECTOR)
revert(0x00, 0x04)
}
// Deduct the amount from the source address's balance.
sstore(srcSlot, sub(srcBalance, amount))
// Reduce the total supply by the amount burned.
let supply := sload(_supply.slot)
sstore(_supply.slot, sub(supply, amount))
// Emit a Transfer event with the destination address as the zero address to indicate burning.
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, src, 0x00, amount)
}
}

Verifies that the source address (src) has a sufficient balance to burn the specified amount of tokens. If the balance is insufficient, the operation reverts with an "Insufficient Balance" error.

Deducts the amount from the src address's balance, ensuring that tokens are properly removed from circulation.

Decreases the total supply of tokens by the same amount, reflecting the reduction in the overall token supply.

Emits a Transfer event with the destination address set to the zero address, signaling that the tokens have been burned.

_computeDomainSeparator function

function _computeDomainSeparator(bytes32 nameHash)
internal
view
virtual
returns (bytes32 domainSeparator)
{
assembly {
let memptr := mload(0x40) // Load the free memory pointer.
mstore(memptr, _EIP712_DOMAIN_PREFIX_HASH) // EIP-712 domain prefix hash.
mstore(add(memptr, 0x20), nameHash) // Token name hash.
mstore(add(memptr, 0x40), _VERSION_1_HASH) // Version hash ("1").
mstore(add(memptr, 0x60), chainid()) // Current chain ID.
mstore(add(memptr, 0x80), address()) // Contract address.
// Compute the EIP-712 domain separator.
domainSeparator := keccak256(memptr, 0xA0)
}
}

The function starts by allocating memory for constructing the domain separator’s components. It uses the free memory pointer to ensure it doesn’t overwrite existing data.

Sequentially stores the EIP-712 domain prefix hash, the hash of the token’s name, the version hash (typically “1” to signify versioning), the current chain ID (to ensure signatures are chain-specific and avoid replay attacks across chains), and the contract’s address.

fter all components are stored in memory, the function computes the domain separator by hashing the concatenated data using keccak256. This hash operation is performed over the entire block of data prepared in memory, resulting in a unique identifier for the contract's domain.

The final code for smart contract would look something like this:

// SPDX-License-Identifier: MIT
// solhint-disable-next-line compiler-version
pragma solidity ^0.8.4;
/// @notice ERC20 (including EIP-2612 Permit) using max inline assembly.
contract ERC20 {
// keccak256("Transfer(address,address,uint256)")
bytes32 internal constant _TRANSFER_HASH =
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef;
// keccak256("Approval(address,address,uint256)")
bytes32 internal constant _APPROVAL_HASH =
0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925;
// first 4 bytes of keccak256("InsufficientBalance()") right padded with 0s
bytes32 internal constant _INSUFFICIENT_BALANCE_SELECTOR =
0xf4d678b800000000000000000000000000000000000000000000000000000000;
// first 4 bytes of keccak256("InsufficientAllowance()") right padded with 0s
bytes32 internal constant _INSUFFICIENT_ALLOWANCE_SELECTOR =
0x13be252b00000000000000000000000000000000000000000000000000000000;
// first 4 bytes of keccak256("InvalidRecipientZero()") right padded with 0s
bytes32 internal constant _RECIPIENT_ZERO_SELECTOR =
0x4c131ee600000000000000000000000000000000000000000000000000000000;
// first 4 bytes of keccak256("InvalidSignature()") right padded with 0s
bytes32 internal constant _INVALID_SIG_SELECTOR =
0x8baa579f00000000000000000000000000000000000000000000000000000000;
// first 4 bytes of keccak256("Expired()") right padded with 0s
bytes32 internal constant _EXPIRED_SELECTOR =
0x203d82d800000000000000000000000000000000000000000000000000000000;
// first 4 bytes of keccak256("StringTooLong()") right padded with 0s
bytes32 internal constant _STRING_TOO_LONG_SELECTOR =
0xb11b2ad800000000000000000000000000000000000000000000000000000000;
// first 4 bytes of keccak256("Overflow()") right padded with 0s
bytes32 internal constant _OVERFLOW_SELECTOR =
0x35278d1200000000000000000000000000000000000000000000000000000000;
// solhint-disable-next-line max-line-length
// keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
bytes32 internal constant _EIP712_DOMAIN_PREFIX_HASH =
0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;
// solhint-disable-next-line max-line-length
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
bytes32 internal constant _PERMIT_HASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
// keccak256("1")
bytes32 internal constant _VERSION_1_HASH =
0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6;
// max 256-bit integer, i.e. 2**256-1
bytes32 internal constant _MAX =
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
// token name, stored in an immutable bytes32 (constructor arg must be <=32 byte string)
bytes32 internal immutable _name;
// token symbol, stored in an immutable bytes32 (constructor arg must be <=32 byte string)
bytes32 internal immutable _symbol;
// token name string length
uint256 internal immutable _nameLen;
// token symbol string length
uint256 internal immutable _symbolLen;
// initial block.chainid, only changes in a future hardfork scenario
uint256 internal immutable _initialChainId;
// initial domain separator, only changes in a future hardfork scenario
bytes32 internal immutable _initialDomainSeparator;
// token balances mapping, storage slot 0x00
mapping(address => uint256) internal _balances;
// token allowances mapping (owner=>spender=>amount), storage slot 0x01
mapping(address => mapping(address => uint256)) internal _allowances;
// token total supply, storage slot 0x02
uint256 internal _supply;
// permit nonces, storage slot 0x03
mapping(address => uint256) internal _nonces;
event Transfer(address indexed src, address indexed dst, uint256 amount);
event Approval(address indexed src, address indexed dst, uint256 amount);
constructor(string memory name_, string memory symbol_) {
/// @dev constructor in solidity bc cannot handle immutables with inline assembly
/// also, constructor gas optimization not really important (one time cost)
// get string lengths
bytes memory nameB = bytes(name_);
bytes memory symbolB = bytes(symbol_);
uint256 nameLen = nameB.length;
uint256 symbolLen = symbolB.length;
// check strings are <=32 bytes
assembly {
if or(lt(0x20, nameLen), lt(0x20, symbolLen)) {
mstore(0x00, _STRING_TOO_LONG_SELECTOR)
revert(0x00, 0x04)
}
}
// compute domain separator
bytes32 initialDomainSeparator = _computeDomainSeparator(
keccak256(nameB)
);
// set immutables
_name = bytes32(nameB);
_symbol = bytes32(symbolB);
_nameLen = nameLen;
_symbolLen = symbolLen;
_initialChainId = block.chainid;
_initialDomainSeparator = initialDomainSeparator;
}
function transfer(address dst, uint256 amount)
public
virtual
returns (bool success)
{
assembly {
// require(dst != address(0), "Address Zero");
if iszero(dst) {
mstore(0x00, _RECIPIENT_ZERO_SELECTOR)
revert(0x00, 0x04)
}
// _balances[msg.sender] -= amount;
mstore(0x00, caller())
mstore(0x20, 0x00)
let srcSlot := keccak256(0x00, 0x40)
let srcBalance := sload(srcSlot)
if lt(srcBalance, amount) {
mstore(0x00, _INSUFFICIENT_BALANCE_SELECTOR)
revert(0x00, 0x04)
}
sstore(srcSlot, sub(srcBalance, amount))
// unchecked { _balances[dst] += amount; }
mstore(0x00, dst)
let dstSlot := keccak256(0x00, 0x40)
sstore(dstSlot, add(sload(dstSlot), amount))
// emit Transfer(msg.sender, dst, amount);
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, caller(), dst)
// return true;
success := 0x01
}
}
// solhint-disable-next-line function-max-lines
function transferFrom(
address src,
address dst,
uint256 amount
) public virtual returns (bool success) {
assembly {
// require(dst != address(0), "Address Zero");
if iszero(dst) {
mstore(0x00, _RECIPIENT_ZERO_SELECTOR)
revert(0x00, 0x04)
}
// uint256 allowanceVal = _allowances[msg.sender][dst];
mstore(0x00, src)
mstore(0x20, 0x01)
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, caller())
let allowanceSlot := keccak256(0x00, 0x40)
let allowanceVal := sload(allowanceSlot)
// if (allowanceVal != _MAX) _allowances[msg.sender][dst] = allowanceVal - amount;
if lt(allowanceVal, _MAX) {
if lt(allowanceVal, amount) {
mstore(0x00, _INSUFFICIENT_ALLOWANCE_SELECTOR)
revert(0x00, 0x04)
}
sstore(allowanceSlot, sub(allowanceVal, amount))
/// @dev NOTE not logging Approval event here, OZ impl does
}
// _balances[src] -= amount;
mstore(0x00, src)
mstore(0x20, 0x00)
let srcSlot := keccak256(0x00, 0x40)
let srcBalance := sload(srcSlot)
if lt(srcBalance, amount) {
mstore(0x00, _INSUFFICIENT_BALANCE_SELECTOR)
revert(0x00, 0x04)
}
sstore(srcSlot, sub(srcBalance, amount))
// unchecked { _balances[dst] += amount; }
mstore(0x00, dst)
let dstSlot := keccak256(0x00, 0x40)
sstore(dstSlot, add(sload(dstSlot), amount))
// emit Transfer(src, dst, amount);
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, src, dst)
// return true;
success := 0x01
}
}
function approve(address dst, uint256 amount)
public
virtual
returns (bool success)
{
assembly {
// _allowances[msg.sender][dst] = amount;
mstore(0x00, caller())
mstore(0x20, 0x01)
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, dst)
sstore(keccak256(0x00, 0x40), amount)
// emit Approval(msg.sender, dst, amount);
mstore(0x00, amount)
log3(0x00, 0x20, _APPROVAL_HASH, caller(), dst)
// return true;
success := 0x01
}
}
// solhint-disable-next-line function-max-lines
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
assembly {
// require(deadline >= block.timestamp, "Expired");
if gt(timestamp(), deadline) {
mstore(0x00, _EXPIRED_SELECTOR)
revert(0x00, 0x04)
}
}
bytes32 separator = DOMAIN_SEPARATOR();
assembly {
// uint256 nonce = _nonces[owner];
mstore(0x00, owner)
mstore(0x20, 0x03)
let nonceSlot := keccak256(0x00, 0x40)
let nonce := sload(nonceSlot)
// bytes32 innerHash =
// keccak256(abi.encode(_PERMIT_HASH, owner, spender, value, nonce, deadline))
let memptr := mload(0x40)
mstore(memptr, _PERMIT_HASH)
mstore(add(memptr, 0x20), owner)
mstore(add(memptr, 0x40), spender)
mstore(add(memptr, 0x60), value)
mstore(add(memptr, 0x80), nonce)
mstore(add(memptr, 0xA0), deadline)
mstore(add(memptr, 0x22), keccak256(memptr, 0xC0))
// bytes32 hash = keccak256(abi.encodePacked("\\x19\\x01", separator, innerHash))
mstore8(memptr, 0x19)
mstore8(add(memptr, 0x01), 0x01)
mstore(add(memptr, 0x02), separator)
mstore(memptr, keccak256(memptr, 0x42))
// address recovered = ecrecover(hash, v, r, s)
mstore(add(memptr, 0x20), v)
mstore(add(memptr, 0x40), r)
mstore(add(memptr, 0x60), s)
if iszero(staticcall(_MAX, 0x01, memptr, 0x80, memptr, 0x20)) {
revert(0x00, 0x00)
}
returndatacopy(memptr, 0x00, returndatasize())
let recovered := mload(memptr)
// require(recovered != address(0) && recovered == owner, "Invalid Signature");
if iszero(and(eq(recovered, owner), gt(recovered, 0x00))) {
mstore(0x00, _INVALID_SIG_SELECTOR)
revert(0x00, 0x04)
}
// unchecked { _nonces[owner] += 1 }
sstore(nonceSlot, add(nonce, 0x01))
// _allowances[recovered][spender] = value;
mstore(0x00, recovered)
mstore(0x20, 0x01)
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, spender)
sstore(keccak256(0x00, 0x40), value)
// emit Approval
mstore(0x00, value)
log3(0x00, 0x20, _APPROVAL_HASH, recovered, spender)
}
}
function allowance(address src, address dst)
public
view
virtual
returns (uint256 amount)
{
assembly {
// return _allowances[src][dst];
mstore(0x00, src)
mstore(0x20, 0x01)
mstore(0x20, keccak256(0x00, 0x40))
mstore(0x00, dst)
amount := sload(keccak256(0x00, 0x40))
}
}
function balanceOf(address src)
public
view
virtual
returns (uint256 amount)
{
assembly {
// return _balances[src];
mstore(0x00, src)
mstore(0x20, 0x00)
amount := sload(keccak256(0x00, 0x40))
}
}
function nonces(address src) public view virtual returns (uint256 nonce) {
assembly {
// return nonces[src];
mstore(0x00, src)
mstore(0x20, 0x03)
nonce := sload(keccak256(0x00, 0x40))
}
}
function totalSupply() public view virtual returns (uint256 amount) {
assembly {
// return _supply;
amount := sload(0x02)
}
}
function name() public view virtual returns (string memory value) {
bytes32 myName = _name;
uint256 myNameLen = _nameLen;
assembly {
// return string(bytes(_name));
value := mload(0x40)
mstore(0x40, add(value, 0x40))
mstore(value, myNameLen)
mstore(add(value, 0x20), myName)
}
}
function symbol() public view virtual returns (string memory value) {
bytes32 mySymbol = _symbol;
uint256 mySymbolLen = _symbolLen;
assembly {
// return string(bytes(_symbol));
value := mload(0x40)
mstore(0x40, add(value, 0x40))
mstore(value, mySymbolLen)
mstore(add(value, 0x20), mySymbol)
}
}
// solhint-disable-next-line func-name-mixedcase
function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
return
block.chainid == _initialChainId
? _initialDomainSeparator
: _computeDomainSeparator(keccak256(abi.encode(_name)));
}
function decimals() public pure virtual returns (uint8 amount) {
assembly {
// return 18;
amount := 0x12
}
}
function _mint(address dst, uint256 amount) internal virtual {
assembly {
// require(dst != address(0), "Address Zero");
if iszero(dst) {
mstore(0x00, _RECIPIENT_ZERO_SELECTOR)
revert(0x00, 0x04)
}
// _supply += amount;
let newSupply := add(amount, sload(0x02))
if lt(newSupply, amount) {
mstore(0x00, _OVERFLOW_SELECTOR)
revert(0x00, 0x04)
}
sstore(0x02, newSupply)
// unchecked { _balances[dst] += amount; }
mstore(0x00, dst)
mstore(0x20, 0x00)
let dstSlot := keccak256(0x00, 0x40)
sstore(dstSlot, add(sload(dstSlot), amount))
// emit Transfer(address(0), dst, amount);
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, 0x00, dst)
}
}
function _burn(address src, uint256 amount) internal virtual {
assembly {
// _balances[src] -= amount;
mstore(0x00, src)
mstore(0x20, 0x00)
let srcSlot := keccak256(0x00, 0x40)
let srcBalance := sload(srcSlot)
if lt(srcBalance, amount) {
mstore(0x00, _INSUFFICIENT_BALANCE_SELECTOR)
revert(0x00, 0x04)
}
sstore(srcSlot, sub(srcBalance, amount))
// unchecked { _supply -= amount; }
sstore(0x02, sub(sload(0x02), amount))
// emit Transfer(src, address(0), amount);
mstore(0x00, amount)
log3(0x00, 0x20, _TRANSFER_HASH, src, 0x00)
}
}
function _computeDomainSeparator(bytes32 nameHash)
internal
view
virtual
returns (bytes32 domainSeparator)
{
assembly {
let memptr := mload(0x40)
mstore(memptr, _EIP712_DOMAIN_PREFIX_HASH)
mstore(add(memptr, 0x20), nameHash)
mstore(add(memptr, 0x40), _VERSION_1_HASH)
mstore(add(memptr, 0x60), chainid())
mstore(add(memptr, 0x80), address())
domainSeparator := keccak256(memptr, 0x100)
}
}
}

Deploying the Contract

To deploy the contract, head over to deploy & run transactionsand enter the name and symbol for our token and then click the deploy button.

You can test all the functions and see if everything works as expected or not.

For any feedback or inquiries, feel free to reach out through Twitter or Linkedin.

--

--

Pari Tomar
Sphere Audits

Researches, talks, shares know-how on building a career in blockchain space