Writing a Good Smart Contract

Overview

As Immutable opens its doors to integrating projects, we are appreciative of the fact that smart contracts may be new territory for many of our new partners. A crucial part of building on Immutable X is having a Layer 1 Ethereum smart contract, which is required for minting assets that can be withdrawn from Immutable X (Layer 2). Therefore, we want to equip you with you with everything you need to write and deploy a smart contract.

Our objectives are to—

  • Get you up to speed on the basics of smart contracts
  • Explain what constitutes a “well-crafted” contract and why
  • Leave you with enough information and guidance to start building with Immutable X

Anatomy of a smart contract

A smart contract is simply a program that runs on the Ethereum blockchain. It is a collection of code (its functions) and data (its state) that resides at a specific address on the Ethereum blockchain. Smart contracts can define rules and automatically enforce them through code, and also cannot be edited once deployed, quite similar to the function of a regular contract. Users can interact with a smart contract by submitting transactions on Ethereum that execute a function defined in the smart contract.

Smart contracts on the Ethereum blockchain are commonly written in Solidity (Solidity docs here), a high level object-orientated programming language. It is not necessary to have experience with Solidity to get started, but this guide will assume you are familiar with similar programming languages like C++, Python, and Javascript.

We recommend that you familiarize yourself with the basics of smart contracts, covered in the official Ethereum documentation here with in-depth explanations and examples - Anatomy of smart contracts.

Here is a simple annotated Hello World example from the Ethereum docs:

// Specifies the version of Solidity, using semantic versioning.
// Learn more: https://solidity.readthedocs.io/en/v0.5.10/layout-of-source-files.html#pragma
pragma solidity ^0.5.10;
​
// Defines a contract named `HelloWorld`.
// A contract is a collection of functions and data (its state).
// Once deployed, a contract resides at a specific address on the Ethereum blockchain.
// Learn more: https://solidity.readthedocs.io/en/v0.5.10/structure-of-a-contract.html
contract HelloWorld {
​
    // Declares a state variable `message` of type `string`.
    // State variables are variables whose values are permanently stored in contract storage.
    // The keyword `public` makes variables accessible from outside a contract
    // and creates a function that other contracts or clients can call to access the value.
    string public message;
​
    // Similar to many class-based object-oriented languages, a constructor is
    // a special function that is only executed upon contract creation.
    // Constructors are used to initialize the contract's data.
    // Learn more: https://solidity.readthedocs.io/en/v0.5.10/contracts.html#constructors
    constructor(string memory initMessage) public {
        // Accepts a string argument `initMessage` and sets the value
        // into the contract's `message` storage variable).
        message = initMessage;
    }
​
    // A public function that accepts a string argument
    // and updates the `message` storage variable.
    function update(string memory newMessage) public {
        message = newMessage;
    }
}

ERC721

In this guide, we will focus specifically on ERC721 tokens, commonly known as non-fungible tokens (NFTs). NFTs allow you to tokenize ownership of any arbitrary data and represent a unique digital asset on the blockchain. The ERC721 standard outlines a set of common rules that all tokens can follow on the Ethereum network to produce expected results. Token standards primarily stipulate the following characteristics about a token:

  • How is ownership decided?
  • How are tokens created?
  • How are tokens transferred?
  • How are tokens burned?

The ERC721 is provided to you as an interface that your NFT contract can inherit functions from, or override for custom implementations.

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
​
contract Doggo is ERC721 {
  constructor() public ERC721("Doggo", "DOG") {}
}

Here we have defined a very simple NFT, and in our constructor we have initalized the name as Doggo and the token symbol to be DOG. This is a perfectly valid NFT already (albeit a boring one), inheriting all the functions from the ERC721 base contract we imported from OpenZeppelin (documentation here).

Metadata

Now that we have an NFT, we can define some on-chain metadata, which refers to properties or characteristics that you want to set for your NFT that is stored in the smart contract itself.

contract NyNFT is ERC721 {
  mapping(uint256 => string) public idToName;
​
  function setName(uint256 tokenId, string _name) public {
    idToName[tokenId] = _name
  }
}

The idToName mapping is stored on-chain and is available for anyone to read from the smart contract. You can set on-chain metadata properties like this through a public function, which means that the name for any token can be changed by anyone sending a transaction calling the setName function. You can set immutable properties for your NFT if you don't expose a way to change it, e.g. setting the name at the time of minting.

However, there are costs associated with storing data on the blockchain. Operations that involve writing to the blockchain, like the setName example above, are relatively expensive for the sender of the transaction. In cases where NFTs represent artworks or other media forms, uploading an entire JPEG to the blockchain will cost way too much and is not feasible. It is therefore common to see most NFT metadata is usually stored off-chain (not in the smart contract itself). You should aim to store as little data as possible on-chain to uniquely identify the value of the NFT (e.g. rarity, character_type).

NFTs often use a Uniform Resource Identifier (URI) for off-chain metadata - a link to an external off-chain resource at which the metadata for that particular asset is stored, usually in a JSON format.This URI is stored in the tokenURI field as part of the ERC721 standard.

function tokenURI(uint256 _tokenId) external view returns (string);

Since this data is off-chain, whoever controls the location at which the metadata is stored has the ability to change it, and if their server is shut down then the metadata will no longer be accessible. These concerns are often why developers choose to use IPFS hashes/links (IFPS Documentation) to guarantee the reliability and immutability of this off-chain data.

Integrating with Immutable X

For a smart contract to work with Immutable X, we need an implementation of a mintFor function, which is what our Stark contract calls at the time of withdrawing a minted token from L2 to L1. There is no smart contract interaction at the time of minting on L2, although the minted token will have a L1 representation, token ID, and immutable metadata (read more about StarkEx, the L2 scalability solution used by Immutable X - High Level Overview).

When minting on Immutable X, you will give us the token ID, which is the L1 token ID representing the token in your smart contract. You also have to provide a blueprint for each token. The blueprint represents the on-chain immutable metadata of the NFT that will be passed (along with the token ID) to your mintFor function. The blueprint string can be of any format you wish, and can typically look like a comma delimited string (e.g. "100,water,2,3") or an IPFS hash. This is passed to the mintFor function in your smart contract, in which you can implement custom logic to decode it on-chain or just save it as it is. We will cover this in more detail below.

Breakdown of example contract

You can find our smart contract templates here on Github here. This repository contains contract examples to get you started with building on Immutable X. You can find a simple implementation of an ERC721 token with the mintFor function implemented correctly to work with Immutable X.

Asset.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
​
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "./Mintable.sol";
​
contract Asset is ERC721, Mintable {
    constructor(
        address _owner,
        string memory _name,
        string memory _symbol,
        address _imx
    ) ERC721(_name, _symbol) Mintable(_owner, _imx) {}
​
    function _mintFor(
        address user,
        uint256 id,
        bytes memory
    ) internal override {
        _safeMint(user, id);
    }
}

This Asset contract inherits from the ERC721 standard, as well as our custom Mintable contract, which we will cover later. The contract implements the _mintFor function which is called by a function in Mintable.sol when the asset is minted to L1 mainnet Ethereum at the time of withdrawal from L2 Immutable X. In this function we call _safeMint which is an inheritied function from the ERC721 contract that mints the NFT in a safe way (see more here). You will be able to use contract as your NFT as is, and the name, symbol, owner, and Immutable X contract address is passed in the constructor.

The use of an underscore before a function name or variable (e.g. _mintFor) is a naming standard in Solidity to indicate that it is an internal function or variable.

Mintable.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
​
import "@openzeppelin/contracts/access/Ownable.sol";
import "./IMintable.sol";
import "./utils/Minting.sol";
​
abstract contract Mintable is Ownable, IMintable {
    address public imx;
    mapping(uint256 => bytes) public blueprints;
​
    event AssetMinted(address to, uint256 id, bytes blueprint);
​
    constructor(address _owner, address _imx) {
        imx = _imx;
        require(_owner != address(0), "Owner must not be empty");
        transferOwnership(_owner);
    }
​
    modifier onlyIMX() {
        require(msg.sender == imx, "Function can only be called by IMX");
        _;
    }
​
    function mintFor(
        address user,
        uint256 quantity,
        bytes calldata mintingBlob
    ) external override onlyIMX {
        require(quantity == 1, "Mintable: invalid quantity");
        (uint256 id, bytes memory blueprint) = Minting.split(mintingBlob);
        _mintFor(user, id, blueprint);
        blueprints[id] = blueprint;
        emit AssetMinted(user, id, blueprint);
    }
​
    function _mintFor(
        address to,
        uint256 id,
        bytes memory blueprint
    ) internal virtual;
}

Going through the Mintable.sol contract above, we can see the asset will be intialized in the constructor with an owner and an imx address.

Immutable X permissions

  • The owner is the wallet address you choose to be the minter of the contract, and it is best to set this to a safe, secure wallet. transferOwnership(_owner) does exactly as described, and transfers the ownership of the contract from the contract deployer to the specific wallet address.
  • The imx address refers to the Immutable X contract address that is interacting with your smart contract do perform minting operations. You can find this address for each environment in the readme of the imx-contracts repository here in the 'Core' row. This address is used in the onlyImX modifier, which checks if the sender of the transaction is our contract or not. This is a way of whitelisting our contract and ensuring that noone else can mint assets through your smart contract.

The mintFor function is called by the Immutable X smart contract at time of withdrawing the NFT to mainnet

The function has the onlyIMX modifier, which is described above.
Since we are minting NFTs that are unique, we ensure that quantity = 1. This field may be used in different contexts such as ERC20 contracts that are compatible with minting on Immutable X (yet to come).
The mintingBlob passed to your smart contract is in the format of '{id}:{blueprint}' and this is decoded into the individual id and blueprint in the Minting utility function (see utils/Minting.sol)
The actual minting of the token is handled by the internal mintFor function.

  • The function emits an event AssetMinted when the mintFor completes successfully, which can be listened on by applications.
  • The blueprint is saved as on-chain immutable metadata in the mapping blueprints. For custom blueprint decoding, you can override the mintFor function in Asset.sol to save it in something like tokenURI or split the string into different components.

Tips for writing a good contract

Design and Development

Smart contracts are immutable once deployed, which is good for trust but also means that bugs in the code will be much more difficult to deal with. You should therefore take as many steps to ensure that your smart contract is bug-free and works as expected before deploying it to mainnet Ethereum, as even small bugs can have devastating consequences when you are handling tokens with market value.

A general tip is to keep your smart contract simple and make use of open source libraries like OpenZeppelin (Open Zeppelin Docs). These contracts have been battle-tested and have lower chances of bugs (though there is never a 100% guarantee).

You can use tools like Remix to quickly experiment and iterate your code, and set up local testing environments by using local blockchains like Ganache. You can use Ethereum testnets (Ropsten, Kovan, etc) as a staging environment, allowing you to test contract deployment and make sure everything works as intended. The Immutable X test environment uses Ropsten, so you should be deploying your contract there first to integrate and play around with the functions on-chain and the integration with Immutable X. You should also be writing unit tests and integration tests for your smart contracts, and we have some example unit tests in Gitbhub to give you an idea of what these can look like.

Gas

Gas in cryptography refers to the computational effort required to execute operations on the blockchain. A gas fee is required to successfully conduct a transaction or execute a smart contract on a blockchain platform, and this gas fee is determined by the gas used multiplied by the gas price. While a key value proposition of Immutable X’s solution is that it is completely free of gas fee within the ecosystem, it is still useful to know about the implications of this for smart contract development.

Generally reading data from the blockchain is free (unless executed in a transaction), but writing data to the blockchain is relatively expensive and costs gas. The is especially the case when minting tokens as all the information about that token is being written to the blockchain and verified by nodes all across the network, which is why you will see pretty hefty gas costs for minting some NFTs on L1.

Your code should therefore try to be as efficient as possible with fewer operations and writes to the blockchain, without compromising security . Most of the computations required in the minting process should be moved off-chain as much as possible. While minting on Immutable X is gas-free, withdrawing the token to mainnet will incur a gas fee to be paid by the user to mint the token.

Metadata

As mentioned previously, it is best to store as little data as possible on the blockchain itself, and use off-chain metadata storage for most attributes or properties of your NFT.

We can look at Gods Unchained as an example: GU cards pass only the proto (the card id) and the quality in its blueprint to save on-chain, and these two properties alone can uniquely determine the value of the card. All the other properties of each card such as attack, defence, image_url, etc is stored in their off-chain metadata api. This means that it is cheaper for a user to withdraw a card from Immutable X as they are writing less data to the blockchain, and also is useful for Gods Unchained to be able to change certain metadata, for example, increasing the Attack of a particular card as part of a balance patch.

It is also common to save a link to a metadata endpoint or an IPFS hash on the blockchain, which allows you to point applications and users to a lot more metadata from your NFT on-chain.

Common mistakes building with Immutable X

Often the blueprint is used to store all the metadata for the token at the time of minting, however the metadata that appears on Immutable X does not read any data from your blueprint at all. There is therefore no reason to define a blueprint as an entire JSON string, and instead it could be a link, hash, or a few select properties to optionally decode and save in custom mappings in your smart contract.

Another common source of confusion is the ERC721 token ID. This usually gets incremented in the minting function on a Layer 1 smart contract, and so this is sometimes mistakenly incremented in custom mintFor functions. However the token ID is defined at the time of minting to Immutable X and passed to the mintFor function in the minting blob, which then gets decoded into the respective ID and blueprint variables. You will have to keep track of the token ID on your end and increment it off-chain for every mint.


Did this page help you?