A1 - Using structs and mappings to manage complex data in Solidity

Write a brief essay on how and why to use structs and mappings together in Solidity contracts to manage complex data.

Introduction

Solidity is a statically typed programming language, used for writing smart contracts on Ethereum. The importance managing complex data, when writing smart contracts, can not be understated. This is because, the more steps a function has to take to complete, the higher the transaction cost will be, due to the computational cost, increasing with computation used. The most expensive is a change in the state of the blockchain, for example, writing data to the blockchain.

Structs in Solidity

Structs are a way to create new types in Solidity. These structs can include different data types. They can be used together with mappings and arrays, or even have mappings or arrays within them. Here is a simple struct of a user profile, from my OrderSystem.sol smart contract.

struct UserProfile {
        // String name
        string name;
        
        // String age
        string age;
        
        // Array of current orders
        uint[] currentOrders;
        
        // Array of completed orders
        uint[] completedOrders;
    }

Mappings in solidity

Mappings are ways of storing data by key. One data type is declared as a key for another type, being the data to be stored and retrieved. This is far less labor intensive than looping through an array, because, in a loop, the execution would read all of the data until it finds what it is looking for. Whereas with mappings you just need the key and without sifting through, you have the data.

// mapping to store user profiles using address as key
    mapping(address => UserProfile) private profiles;

Combining Structs and Mappings together

Following on from the example above, I mapped the users address to their UserProfile struct, allowing quick, easy entry and retreival of user and order information. You can see it all in action with the two functions below. The first takes two strings, to add the name and age of the user. The second is a standard getter function, to retrieve this information, using the users address.

// Sets the user profile details
function newUserProfile(string memory _name, string memory _age) public {
        
        profiles[msg.sender].name = _name;
        
        profiles[msg.sender].age = _age;
}

// Takes a users address as input, and returns user details.
function getProfile(
        address _user
    ) public view onlyOwner returns (string memory, string memory) {

        return (profiles[_user].name, profiles[_user].age);
    }

Benefits of using them together

The example below shows two different contracts. One uses a mapping to store and retrieve the data, whereas the other demonstrates the use of an array for the same purpose. One noteable benefit is more organised and readable code. Storing the data in arrays, requires loops to sift through the data. Another is the noticable reduction in gas fees and transaction costs. I also noted the gas and execution fees for each function call. The storing of the user profile using a mapping saved around 38% on gas fees. Another benefit is scalability and flexibility. The larger an array gets, the more computing power it will take to sift through the information. With mappings however there is just one key, that points to the exact piece of information, making it highly scalable.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

// -------------- MAPPING CONTRACT --------------
contract NewUserProfileMapping {

    // Maps an address to the users profile
    mapping(address => UserProfile) private profiles;

    // The user profile struct
    struct UserProfile {
            string name;
            string age;
    }

    // Sets the user profile details using a mapping
    function newUserProfileMap(string memory _name, string memory _age) public {
            
            profiles[msg.sender].name = _name;
            
            profiles[msg.sender].age = _age;
    }
    //gas	79582 gas
    // transaction cost	69201 gas 
    // execution cost	47261 gas 



    // Takes a users address as input, and returns user details.
    function getProfileMap(
            address _user
        ) public view returns (string memory, string memory) {

            return (profiles[_user].name, profiles[_user].age);
        }
    // execution cost	7104 gas (Cost only applies when called by a contract)

}

// -------------- ARRAY CONTRACT --------------
contract newUserProfileArray {

    // An array to store the user profile structs
    UserProfile[] private users;

    // The user profile struct
    struct UserProfile {
            string name;
            string age;
            address userAddress;
    }


    // Sets the user profile details and stores them in the array
    function newUserProfileArr(string memory _name, string memory _age) public {
            
            users.push(UserProfile(_name, _age, msg.sender));
    }
    // gas	130538 gas
    // transaction cost	113511 gas 
    // execution cost	91571 gas 



    // Takes a users address as input, loops through the array and returns user details.
    function getProfileArr() public view returns (string memory, string memory) {
            for (uint i = 0; i < users.length; i++) {
                if (users[i].userAddress == msg.sender) {
                    return (users[i].name, users[i].age);
                }
            }
    
        return ("", "");
            
    }
    // 	11885 gas (Cost only applies when called by a contract)
}

Challenges and Considerations

Gas Costs

As you can see from the smart contract above, the gas saving when using mappings over arrays are absolutely huge. Even beyond the figures you can see above, when each function was only called once. You really see the difference when more new users are added to the array. Where the initial execution cost for a smart contract to call the getProfileLoop function was around 12,000 gas. Once I added 5 users to the array, the execution cost has nearly doubled to around 24,000. If you are new to smart contract development, I encourage you to paste the contract into remix and try it for yourself.

Mapping Limitations

One limitation of mappings is that we cannot be enumerated over. There are many cases in programming where, a list of data is highly necessary. In certain functions and cases, it is neccesary to iterate over everything in a list. One way of overcoming this is to use both together in a smart contract and reap the benefits of both. An example of this can be found in Patrick Collins' smart contract lottery, where the address of all players in the lottery are kept in an array. You can see the full code here. This is so that a random number can be used as the array position to pick a winner. For beginners I would highly recommend following Patrick Collins' javascript course, where he teaches you how to build your very own smart contract lottery, using chainlinks Verified Random Function.

Real world applications

There are many decentralised applications that use structs and mappings. Some dapps that would benefit from the use of them would be: decentralised voting systems or marketplaces. Defi applications that offer lending, borrowing and insurance would also use them for management of user funds.

I created a simple order system, that used structs to manage user profiles and orders. However it is only arbitrary and purely for testing purposes. You can see the full code here. I have not, as of the date of writing, coded it towards any real use case.

One example, is the well-known erc-20 token standard. If you want to look at the full contract code, you can see it here on the openZeppelin github. The mapping below shows the mapping of an address, to a balance. This of course represents the user balance.

// The users address has a balance value mapped to it.
mapping(address account => uint256) private _balances;

// Takes the address as input and returns the balance of that address.
function balanceOf(address account) public view virtual returns (uint256) {
        return _balances[account];
    }

The above is of course a very simple example of how the erc-20 smart contract utilises the balance. In the case of a transfer, the mapping would be used to take away a value from the 'from' user balance and add it to the 'to' user balance. Below is a simplified version of transfer function for reference.

// Transfer function (simplified)
function transfer(address recipient, uint256 amount) public returns (bool) {
        require(_balances[msg.sender] >= amount, "Not enough tokens"); 

        _balances[msg.sender] -= amount;
        _balances[recipient] += amount;
        return true;
   }

Conclusion

To recap, structs are a way of creating new types in solidity, that can include several different data types. Mappings are a way of storing data using a key. You can combine structs and mappings together, for efficient information creation and retrieval. The benefits of using the two together are: noticably cleaner, tidier code. In addition to, more gas efficient smart contracts. All of this was tested using the two contracts here. On all metrics I tested in that particular context, mapping was the clear winner.

In conclusion, using mappings and structs together in smart contracts, is a fundimental and crucial concept for any smart contract developer to master. Doing this will enhance anyones ability to write clean, gas efficient and scalable smart contracts that will ultimately enhance the end user experience and smart contract security for the better.

References

Official Solidity Documentation - https://docs.soliditylang.org/en/v0.8.24/

Open Zeppilin Github - https://github.com/OpenZeppelin

Patrick Collins youtube channel - https://www.youtube.com/@PatrickAlphaC

My socials

Github - https://github.com/SimSimButDifferent

Twitter - https://twitter.com/Simsimbutdev

Feedback

As usual I prompt chatGpt:

User

please give your second feedback, with your first points in mind, about my structs and mappings essay: {paste essay here}

ChatGpt

Your revised essay on using structs and mappings in Solidity provides a comprehensive overview of these features, emphasizing their importance in managing complex data efficiently. Here's feedback considering the initial points:

Strengths:

  1. Clear Introduction: The introduction effectively sets the stage for why managing complex data efficiently is crucial in Solidity, particularly highlighting the impact on gas costs.

  2. Practical Examples: The inclusion of code snippets from your OrderSystem.sol smart contract and comparison between mappings and arrays offer tangible insights into how structs and mappings are utilized. This practical approach helps readers grasp the concepts more concretely.

  3. Discussion on Gas Efficiency: The detailed comparison of gas costs between mappings and arrays illuminates one of the key benefits of using mappings for certain data retrieval scenarios, providing a strong argument for their use in smart contracts.

  4. Real-World Applications and Limitations: You've effectively expanded on real-world applications of structs and mappings, such as in ERC-20 tokens, and introduced a section on the limitations of mappings, specifically their inability to be enumerated. This adds depth to your analysis.

Areas for Improvement:

  1. Clarification on Enumeration: While you've introduced the limitation regarding the inability to enumerate mappings, further elaboration on how combining mappings with arrays can overcome this limitation would be beneficial. This could include a brief example or theoretical explanation.

  2. Structural Clarity: The essay could benefit from clearer section divisions, particularly between the introduction of concepts (structs, mappings) and their practical application or comparison. Subheadings could enhance readability and organization.

  3. Proofreading and Consistency: There are minor spelling and grammatical errors (e.g., "Challanges" should be "Challenges", "neccesary" should be "necessary"). Additionally, ensuring consistency in formatting code comments and explanations would improve the professional quality of the text.

  4. Expansion on Real-World Use Cases: While real-world applications are mentioned, delving a bit deeper into specific scenarios or case studies where the combination of structs and mappings has been particularly advantageous could provide readers with a richer understanding.

  5. Conclusion Refinement: The conclusion succinctly recaps the essay's main points but could further tie back to the introductory remarks on computational cost and the blockchain's immutable nature. Highlighting how the efficient management of complex data contributes to broader goals like scalability, security, and user experience could offer a strong closing argument.

Conclusion:

Your essay demonstrates a solid understanding of structs and mappings in Solidity and their significance in developing efficient, scalable, and clean smart contracts. The improvements you've made based on the initial feedback have notably enhanced the essay's clarity and depth. Continuing to refine your analysis and presentation will make your discussions on Solidity concepts even more compelling and informative. Great work on iterating on your essay and deepening your exploration of these essential Solidity features!

Last updated