Module 4: Complex Data Structures

Module 4: Complex Data Structures in Solidity

4.1 Arrays in Solidity

Arrays in Solidity are collections of elements of the same type. They are useful for storing and managing lists of data in your smart contracts.

Types of Arrays

Solidity supports two types of arrays:

  1. Fixed-size arrays: The length is determined at declaration and cannot be changed.
  2. Dynamic arrays: The length can change during execution.

Declaring Arrays

// Fixed-size array of 5 unsigned integers
uint[5] public fixedArray;

// Dynamic array of unsigned integers
uint[] public dynamicArray;

// Fixed-size array with initialization
uint[3] public initializedArray = [1, 2, 3];

// Dynamic array with initialization
uint[] public initializedDynamicArray = [1, 2, 3];

// Dynamic array of strings
string[] public names;

// Two-dimensional array
uint[3][2] public matrix; // 2 rows, 3 columns

Array Operations

Adding Elements

For dynamic arrays, you can add elements using the push method:

function addElement(uint value) public {
    dynamicArray.push(value);
}

Removing Elements

Solidity doesn't have a built-in method to remove elements from arrays. However, you can implement this functionality by shifting elements or replacing the element to be removed with the last element and then removing the last element:

function removeElement(uint index) public {
    require(index < dynamicArray.length, "Index out of bounds");

    // Replace the element to remove with the last element
    dynamicArray[index] = dynamicArray[dynamicArray.length - 1];

    // Remove the last element
    dynamicArray.pop();
}

Accessing Elements

You can access array elements using their index:

function getElement(uint index) public view returns (uint) {
    require(index < dynamicArray.length, "Index out of bounds");
    return dynamicArray[index];
}

Getting Array Length

You can get the length of an array using the length property:

function getArrayLength() public view returns (uint) {
    return dynamicArray.length;
}

Memory vs. Storage Arrays

In Solidity, arrays can be stored in storage or memory:

// Storage array (state variable)
uint[] public storageArray;

function memoryArrayExample() public pure returns (uint[] memory) {
    // Memory array (local variable)
    uint[] memory memoryArray = new uint[](3);
    memoryArray[0] = 1;
    memoryArray[1] = 2;
    memoryArray[2] = 3;
    return memoryArray;
}

Array Limitations and Gas Considerations

Arrays in Solidity have some limitations and gas considerations:

  1. Gas costs: Operations on large arrays can be expensive, especially when modifying storage arrays.
  2. Array length: There's no built-in way to reduce the length of a storage array except by using pop().
  3. Return values: You cannot return dynamic arrays from external functions.
  4. Copying arrays: Copying arrays from storage to memory can be expensive.

Practical Example: Todo List

Let's implement a simple todo list using arrays:

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

contract TodoList {
    struct Task {
        string content;
        bool completed;
    }

    Task[] public tasks;

    function createTask(string memory _content) public {
        tasks.push(Task({
            content: _content,
            completed: false
        }));
    }

    function toggleCompleted(uint _index) public {
        require(_index < tasks.length, "Task does not exist");
        tasks[_index].completed = !tasks[_index].completed;
    }

    function getTaskCount() public view returns (uint) {
        return tasks.length;
    }
}

4.2 Mappings in Solidity

Mappings in Solidity are hash tables that store key-value pairs. They are useful for creating associations between data.

Declaring Mappings

The syntax for declaring a mapping is:

mapping(keyType => valueType) visibility variableName;

Examples:

// Mapping from address to uint
mapping(address => uint) public balances;

// Mapping from uint to string
mapping(uint => string) public names;

// Mapping from address to boolean
mapping(address => bool) public hasVoted;

Key Types

The key type can be any built-in value type, bytes, string, or any contract or enum type. Reference types like arrays, structs, and mappings cannot be used as key types.

Value Types

The value type can be any type, including mappings, arrays, and structs.

Nested Mappings

You can create nested mappings for more complex data structures:

// Mapping from address to a mapping from uint to bool
mapping(address => mapping(uint => bool)) public userItemOwnership;

This creates a two-dimensional mapping where each address maps to another mapping from uint to bool.

Mapping Operations

Setting Values

function setBalance(address user, uint amount) public {
    balances[user] = amount;
}

Getting Values

function getBalance(address user) public view returns (uint) {
    return balances[user];
}

Checking if a Key Exists

Mappings in Solidity don't have a concept of "existence." All possible keys exist by default and map to the default value of the value type (0, false, empty string, etc.).

function hasBalance(address user) public view returns (bool) {
    // This doesn't check if the key exists, but if the value is non-zero
    return balances[user] > 0;
}

Mapping vs. Array

When deciding between mappings and arrays, consider:

  1. Access pattern: If you need to access elements by a specific key, use a mapping. If you need to iterate over elements, use an array.
  2. Existence check: Mappings don't have a built-in way to check if a key exists.
  3. Iteration: You cannot iterate over a mapping or get its size.
  4. Default values: All keys in a mapping exist and map to the default value of the value type.

Practical Example: Token Balances

Let's implement a simple token contract using mappings:

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

contract SimpleToken {
    string public name = "SimpleToken";
    string public symbol = "ST";
    uint8 public decimals = 18;
    uint256 public totalSupply = 1000000 * 10**18;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor() {
        balanceOf[msg.sender] = totalSupply;
    }

    function transfer(address to, uint256 value) public returns (bool success) {
        require(to != address(0), "Transfer to zero address");
        require(balanceOf[msg.sender] >= value, "Insufficient balance");

        balanceOf[msg.sender] -= value;
        balanceOf[to] += value;

        emit Transfer(msg.sender, to, value);
        return true;
    }

    function approve(address spender, uint256 value) public returns (bool success) {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value) public returns (bool success) {
        require(to != address(0), "Transfer to zero address");
        require(balanceOf[from] >= value, "Insufficient balance");
        require(allowance[from][msg.sender] >= value, "Insufficient allowance");

        balanceOf[from] -= value;
        balanceOf[to] += value;
        allowance[from][msg.sender] -= value;

        emit Transfer(from, to, value);
        return true;
    }
}

4.3 Structs in Solidity

Structs in Solidity are custom defined types that can group several variables. They are useful for creating complex data structures.

Declaring Structs

struct Person {
    string name;
    uint age;
    address wallet;
    bool active;
}

Using Structs

Structs can be used as state variables, function parameters, and return values:

// State variable
Person public person1;

// Array of structs
Person[] public people;

// Mapping with struct as value
mapping(address => Person) public addressToPerson;

Initializing Structs

There are several ways to initialize a struct:

// Method 1: Positional initialization
Person memory person2 = Person("Alice", 30, 0x123..., true);

// Method 2: Named initialization
Person memory person3 = Person({
    name: "Bob",
    age: 25,
    wallet: 0x456...,
    active: true
});

// Method 3: Initialize and then set properties
Person memory person4;
person4.name = "Charlie";
person4.age = 35;
person4.wallet = 0x789...;
person4.active = false;

Structs in Memory and Storage

Like arrays, structs can be stored in storage or memory:

// Storage struct (state variable)
Person public storagePerson;

function memoryStructExample() public view returns (string memory) {
    // Memory struct (local variable)
    Person memory memoryPerson = Person("Dave", 40, 0xabc..., true);
    return memoryPerson.name;
}

function updatePerson(string memory _name, uint _age) public {
    // Update storage struct
    storagePerson.name = _name;
    storagePerson.age = _age;
}

Nested Structs

Structs can contain other structs:

struct Address {
    string street;
    string city;
    string country;
}

struct Person {
    string name;
    uint age;
    Address homeAddress;
}

Practical Example: Voting System

Let's implement a voting system using structs:

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

contract VotingSystem {
    struct Candidate {
        uint id;
        string name;
        uint voteCount;
    }

    struct Voter {
        bool hasVoted;
        uint votedCandidateId;
    }

    mapping(address => Voter) public voters;
    Candidate[] public candidates;

    constructor(string[] memory candidateNames) {
        for (uint i = 0; i < candidateNames.length; i++) {
            candidates.push(Candidate({
                id: i,
                name: candidateNames[i],
                voteCount: 0
            }));
        }
    }

    function vote(uint candidateId) public {
        Voter storage sender = voters[msg.sender];
        require(!sender.hasVoted, "You have already voted");
        require(candidateId < candidates.length, "Invalid candidate");

        sender.hasVoted = true;
        sender.votedCandidateId = candidateId;

        candidates[candidateId].voteCount++;
    }

    function getCandidateCount() public view returns (uint) {
        return candidates.length;
    }

    function getWinningCandidate() public view returns (uint winningCandidateId) {
        uint winningVoteCount = 0;

        for (uint i = 0; i < candidates.length; i++) {
            if (candidates[i].voteCount > winningVoteCount) {
                winningVoteCount = candidates[i].voteCount;
                winningCandidateId = i;
            }
        }
    }
}

4.4 Combining Complex Data Structures

In real-world applications, you'll often need to combine arrays, mappings, and structs to create more complex data structures.

Example: Marketplace Contract

Let's create a marketplace contract that combines various data structures:

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

contract Marketplace {
    struct Product {
        uint id;
        string name;
        string description;
        uint price;
        address payable seller;
        bool active;
    }

    struct Order {
        uint productId;
        address buyer;
        uint timestamp;
        bool completed;
    }

    // Array of all products
    Product[] public products;

    // Array of all orders
    Order[] public orders;

    // Mapping from product ID to product index in the array
    mapping(uint => uint) public productIdToIndex;

    // Mapping from user address to array of product IDs they're selling
    mapping(address => uint[]) public sellerProducts;

    // Mapping from user address to array of order IDs they've placed
    mapping(address => uint[]) public buyerOrders;

    // Counter for product IDs
    uint public nextProductId = 1;

    // Event for product creation
    event ProductCreated(uint productId, string name, uint price, address seller);

    // Event for product purchase
    event ProductPurchased(uint productId, address buyer, uint price);

    function createProduct(string memory _name, string memory _description, uint _price) public {
        require(_price > 0, "Price must be greater than zero");

        uint productId = nextProductId++;

        products.push(Product({
            id: productId,
            name: _name,
            description: _description,
            price: _price,
            seller: payable(msg.sender),
            active: true
        }));

        uint index = products.length - 1;
        productIdToIndex[productId] = index;
        sellerProducts[msg.sender].push(productId);

        emit ProductCreated(productId, _name, _price, msg.sender);
    }

    function purchaseProduct(uint _productId) public payable {
        require(_productId > 0 && _productId < nextProductId, "Invalid product ID");

        uint index = productIdToIndex[_productId];
        Product storage product = products[index];

        require(product.active, "Product is not active");
        require(msg.value >= product.price, "Insufficient funds");
        require(msg.sender != product.seller, "Seller cannot buy their own product");

        // Create order
        orders.push(Order({
            productId: _productId,
            buyer: msg.sender,
            timestamp: block.timestamp,
            completed: false
        }));

        uint orderId = orders.length - 1;
        buyerOrders[msg.sender].push(orderId);

        // Transfer funds to seller
        product.seller.transfer(msg.value);

        // Mark product as inactive
        product.active = false;

        emit ProductPurchased(_productId, msg.sender, msg.value);
    }

    function getProductCount() public view returns (uint) {
        return products.length;
    }

    function getOrderCount() public view returns (uint) {
        return orders.length;
    }

    function getSellerProductCount(address _seller) public view returns (uint) {
        return sellerProducts[_seller].length;
    }

    function getBuyerOrderCount(address _buyer) public view returns (uint) {
        return buyerOrders[_buyer].length;
    }

    function completeOrder(uint _orderId) public {
        require(_orderId < orders.length, "Invalid order ID");

        Order storage order = orders[_orderId];
        uint productIndex = productIdToIndex[order.productId];
        Product storage product = products[productIndex];

        require(msg.sender == product.seller, "Only seller can complete the order");
        require(!order.completed, "Order already completed");

        order.completed = true;
    }
}

This marketplace contract demonstrates how to combine arrays, mappings, and structs to create a complex application. It allows users to:

  1. Create products with details like name, description, and price
  2. Purchase products by sending Ether
  3. Track orders and their status
  4. Manage relationships between sellers, products, buyers, and orders

Best Practices for Complex Data Structures

When working with complex data structures in Solidity, keep these best practices in mind:

  1. Gas Optimization: Complex data structures can be expensive to store and manipulate. Consider the gas costs of your operations.

  2. Data Location: Be mindful of whether your data is in storage or memory, as this affects gas costs and behavior.

  3. Avoid Unbounded Operations: Avoid operations that could grow indefinitely, such as unbounded loops over arrays.

  4. Use Events for History: Instead of storing historical data in arrays, consider using events to log history.

  5. Consider Off-Chain Storage: For large amounts of data, consider storing only hashes on-chain and keeping the actual data off-chain.

  6. Validate Inputs: Always validate inputs to prevent unexpected behavior, especially when working with array indices.

  7. Use Libraries: Consider using libraries for complex data structure operations to reduce contract size and improve reusability.

Exercise: Create a Supply Chain Contract

Task: Create a supply chain contract that tracks the movement of a product from manufacturer to consumer, using complex data structures to store and manage the data.

Solution:

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

contract SupplyChain {
    enum ProductStatus { Created, Shipped, Delivered }

    struct Product {
        uint id;
        string name;
        string description;
        uint price;
        address manufacturer;
        address currentOwner;
        ProductStatus status;
        uint timestamp;
    }

    struct TrackingUpdate {
        uint productId;
        address handler;
        ProductStatus status;
        string location;
        uint timestamp;
    }

    // Array of all products
    Product[] public products;

    // Array of all tracking updates
    TrackingUpdate[] public trackingUpdates;

    // Mapping from product ID to array of tracking update indices
    mapping(uint => uint[]) public productToUpdates;

    // Mapping from address to array of product IDs they own
    mapping(address => uint[]) public ownerToProducts;

    // Counter for product IDs
    uint public nextProductId = 1;

    // Events
    event ProductCreated(uint productId, string name, address manufacturer);
    event ProductShipped(uint productId, address from, address to, string location);
    event ProductDelivered(uint productId, address to, string location);

    function createProduct(string memory _name, string memory _description, uint _price) public {
        uint productId = nextProductId++;

        products.push(Product({
            id: productId,
            name: _name,
            description: _description,
            price: _price,
            manufacturer: msg.sender,
            currentOwner: msg.sender,
            status: ProductStatus.Created,
            timestamp: block.timestamp
        }));

        // Add tracking update
        addTrackingUpdate(productId, ProductStatus.Created, "Manufacturer");

        // Add to owner's products
        ownerToProducts[msg.sender].push(productId);

        emit ProductCreated(productId, _name, msg.sender);
    }

    function shipProduct(uint _productId, address _to, string memory _location) public {
        require(_productId > 0 && _productId < nextProductId, "Invalid product ID");

        Product storage product = products[_productId - 1];

        require(msg.sender == product.currentOwner, "Only current owner can ship");
        require(product.status == ProductStatus.Created, "Product must be in Created status");

        product.status = ProductStatus.Shipped;

        // Add tracking update
        addTrackingUpdate(_productId, ProductStatus.Shipped, _location);

        emit ProductShipped(_productId, msg.sender, _to, _location);
    }

    function deliverProduct(uint _productId, address _to, string memory _location) public {
        require(_productId > 0 && _productId < nextProductId, "Invalid product ID");

        Product storage product = products[_productId - 1];

        require(msg.sender == product.currentOwner, "Only current owner can deliver");
        require(product.status == ProductStatus.Shipped, "Product must be in Shipped status");

        // Update product
        product.status = ProductStatus.Delivered;
        product.currentOwner = _to;

        // Remove from previous owner's products
        removeFromOwnerProducts(msg.sender, _productId);

        // Add to new owner's products
        ownerToProducts[_to].push(_productId);

        // Add tracking update
        addTrackingUpdate(_productId, ProductStatus.Delivered, _location);

        emit ProductDelivered(_productId, _to, _location);
    }

    function addTrackingUpdate(uint _productId, ProductStatus _status, string memory _location) internal {
        trackingUpdates.push(TrackingUpdate({
            productId: _productId,
            handler: msg.sender,
            status: _status,
            location: _location,
            timestamp: block.timestamp
        }));

        uint updateId = trackingUpdates.length - 1;
        productToUpdates[_productId].push(updateId);
    }

    function removeFromOwnerProducts(address _owner, uint _productId) internal {
        uint[] storage ownerProducts = ownerToProducts[_owner];

        for (uint i = 0; i < ownerProducts.length; i++) {
            if (ownerProducts[i] == _productId) {
                // Replace with the last element and remove the last element
                ownerProducts[i] = ownerProducts[ownerProducts.length - 1];
                ownerProducts.pop();
                break;
            }
        }
    }

    function getProductCount() public view returns (uint) {
        return products.length;
    }

    function getTrackingUpdateCount(uint _productId) public view returns (uint) {
        return productToUpdates[_productId].length;
    }

    function getOwnerProductCount(address _owner) public view returns (uint) {
        return ownerToProducts[_owner].length;
    }

    function getProductTrackingUpdates(uint _productId) public view returns (TrackingUpdate[] memory) {
        uint[] storage updateIds = productToUpdates[_productId];
        TrackingUpdate[] memory updates = new TrackingUpdate[](updateIds.length);

        for (uint i = 0; i < updateIds.length; i++) {
            updates[i] = trackingUpdates[updateIds[i]];
        }

        return updates;
    }
}

This supply chain contract demonstrates how to use complex data structures to track products through a supply chain. It allows:

  1. Creating products with details
  2. Shipping products to new owners
  3. Delivering products to final recipients
  4. Tracking the history of each product
  5. Managing relationships between products, owners, and tracking updates

Try implementing this contract in Remix and test the different functions to see how the data structures work together!

In the next module, we'll explore events and logging in Solidity, which are essential for building interactive decentralized applications.