LearnWeb3 Logo

16 min read

·

3 years ago

+7,000 XP
387
55
17

Digging deeper into Solidity's syntax

In the Introduction to Solidity lesson we went over some basic Solidity syntax - things like variables, data types, functions, loops, conditional flows, and arrays. In this lesson, we continue to dig deeper into Solidity's syntax and other features which will be important as you keep building contracts and more projects.


Mappings

Mappings in Solidity act like hashmaps or dictionaries in other programming languages. They are used to store the data in key-value pairs.


They are created using the syntax mapping(keyType ⇒ valueType)


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Mapping { // Mapping from address to uint mapping(address => uint) public myMap; function get(address _addr) public view returns (uint) { // Mapping always returns a value. // If the value was never set, it will return the default value. // The default value for uint is 0 return myMap[_addr]; } function set(address _addr, uint _i) public { // Update the value at this address myMap[_addr] = _i; } function remove(address _addr) public { // Reset the value to the default value. delete myMap[_addr]; } }


We can also create nested mappings, where each value in a mapping points to another mapping. To do so, we set the valueType to a mapping itself.


contract NestedMappings { // Mapping from address => (mapping from uint to bool) mapping(address => mapping(uint => bool)) public nestedMap; function get(address _addr1, uint _i) public view returns (bool) { // You can get values from a nested mapping // even when it is not initialized // The default value for a bool type is false return nestedMap[_addr1][_i]; } function set( address _addr1, uint _i, bool _boo ) public { nestedMap[_addr1][_i] = _boo; } function remove(address _addr1, uint _i) public { delete nestedMap[_addr1][_i]; } }


What is an analogous data structure to mappings?

  • Arrays

  • Trees

  • Graphs

  • Hashmaps


Enums

The word Enum stands for Enumerable. They are user defined types that contain human readable names for a set of constants, called members. They are commonly used to restrict a variable to only have one of a few predefined values. Since they are just an abstraction for human readable constants, in actuality, they are internally represented as uints.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Enum { // Enum representing different possible shipping states enum Status { Pending, Shipped, Accepted, Rejected, Canceled } // Declare a variable of the type Status // This can only contain one of the predefined values Status public status; // Since enums are internally represented by uints // This function will always return a uint // Pending = 0 // Shipped = 1 // Accepted = 2 // Rejected = 3 // Canceled = 4 // Value higher than 4 cannot be returned function get() public view returns (Status) { return status; } // Pass the desired Status enum value as a uint function set(Status _status) public { status = _status; } // Update status enum value to a specific enum member, in this case, to the Canceled enum value function cancel() public { status = Status.Canceled; // Will set status = 4 } }


What are enums used for?

  • To restrict a variable to have one of a few predefined values

  • To restrict a function to have one of a few predefined values


Structs

The concept of structs exists in many high level programming languages. They are used to define your own data types which group together related data.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract TodoList { // Declare a struct which groups together two data types struct TodoItem { string text; bool completed; } // Create an array of TodoItem structs TodoItem[] public todos; function createTodo(string memory _text) public { // There are multiple ways to initialize structs // Method 1 - Call it like a function todos.push(TodoItem(_text, false)); // Method 2 - Explicitly set its keys todos.push(TodoItem({ text: _text, completed: false })); // Method 3 - Initialize an empty struct, then set individual properties TodoItem memory todo; todo.text = _text; todo.completed = false; todos.push(todo); } // Update a struct value function update(uint _index, string memory _text) public { todos[_index].text = _text; } // Update completed function toggleCompleted(uint _index) public { todos[_index].completed = !todos[_index].completed; } }


What are structs used for?

  • Creating custom data types

  • Restricting variables to have one of a few predefined values

  • Storing key-value pairs

  • Logging values to the blockchain


View and Pure Functions


You might have noticed that some of the functions we have been writing specify one of either a view or pure keyword in the function header. These are special keywords which indicate specific behavior for the function.


Getter functions (those which return values) can be declared either view or pure.

  • View: Functions which do not change any state values

  • Pure: Functions which do not change any state values and also do not read any state values


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract ViewAndPure { // Declare a state variable uint public x = 1; // Promise not to modify the state (but can read state) function addToX(uint y) public view returns (uint) { return x + y; } // Promise not to modify or read from state function add(uint i, uint j) public pure returns (uint) { return i + j; } }


What is the difference between view and pure functions?

  • view functions can read state, pure functions cannot

  • pure functions can read state, view functions cannot


Modifiers

Modifiers are code that can be run before and/or after a function call. They are commonly used for restricting access to certain functions, validating input parameters, protecting against certain types of attacks, etc.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Modifiers { address public owner; constructor() { // Set the contract deployer as the owner of the contract owner = msg.sender; } // Create a modifier that only allows a function to be called by the owner modifier onlyOwner() { require(msg.sender == owner, "You are not the owner"); // Underscore is a special character used inside modifiers // Which tells Solidity to execute the function the modifier is used on // at this point // Therefore, this modifier will first perform the above check // Then run the rest of the code _; } // Create a function and apply the onlyOwner modifier on it function changeOwner(address _newOwner) public onlyOwner { // We will only reach this point if the modifier succeeds with its checks // So the caller of this transaction must be the current owner owner = _newOwner; } }


Events

Events allow contracts to perform logging on the Ethereum blockchain. Logs for a given contract can be parsed later to perform updates on the frontend interface, for example. They are commonly used to allow frontend interfaces to listen for specific events and update the user interface, or used as a cheap form of storage.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Events { // Declare an event which logs an address and a string event TestCalled(address sender, string message); function test() public { // Log an event emit TestCalled(msg.sender, "Someone called test()!"); } }


What are events used for?

  • Logging values to the blockchain

  • Creating custom data types


Constructors

A constructor is an optional function that is executed when the contract is first deployed. Unlike other functions, constructors cannot be manually called ever again after the contract has been deployed, therefore it can only ever run once. You can also pass arguments to constructors before the contract is deployed.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract X { string public name; // You will need to provide a string argument when deploying the contract constructor(string memory _name) { // This will be set immediately when the contract is deployed name = _name; } }


Contract Inheritance

Inheritance is the procedure by which one contract can inherit the attributes and methods of another contract. Solidity supports multiple inheritance. Contracts can inherit other contract by using the is keyword.


A parent contract which has a function that can be overridden by a child contract must be declared as a virtual function.


A child contract that is going to override a parent function must use the override keyword.


The order of inheritance matters if parent contracts share methods or attributes by the same name.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; /* Graph of inheritance A / \ B C |\ /| | \/ | | /\ | D E */ contract A { // Declare a virtual function foo() which can be overridden by children function foo() public pure virtual returns (string memory) { return "A"; } } contract B is A { // Override A.foo(); // But also allow this function to be overridden by further children // So we specify both keywords - virtual and override function foo() public pure virtual override returns (string memory) { return "B"; } } contract C is A { // Similar to contract B above function foo() public pure virtual override returns (string memory) { return "C"; } } // When inheriting from multiple contracts, if a function is defined multiple times, the right-most parent contract's function is used. contract D is B, C { // D.foo() returns "C" // since C is the right-most parent with function foo(); // override (B,C) means we want to override a method that exists in two parents function foo() public pure override (B, C) returns (string memory) { // super is a special keyword that is used to call functions // in the parent contract return super.foo(); } } contract E is C, B { // E.foo() returns "B" // since B is the right-most parent with function foo(); function foo() public pure override (C, B) returns (string memory) { return super.foo(); } }


contract B is A {…}

  • A is inheriting B

  • B is inheriting A

  • A and B are the same contract


ETH Transfers

There are three ways to transfer ETH from a contract to some other address. Here will go over all of them analysing their strengths and weakness and conclude on the best of the 3.


  1. Using the send function


    The send function sends a specified amount of Ether to an address. It is a low-level function that only provides a stipend of 2300 gas for execution. This amount of gas is only enough to emit an event. If the execution requires more gas, it will fail. The send function does not throw an exception when it fails, instead, it returns false. This means you should always check the result of send and handle the failure case manually

// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract SendEther { function sendEth(address payable _to) public payable { // Just forward the ETH received in this payable function // to the given address uint amountToSend = msg.value; bool sent = _to.send(amountToSend); require(sent == true, "Failed to send ETH"); } }



  1. Using the call function


The call function is a low-level function that transfers Ether and also forwards all remaining gas. This means call can be used to send Ether and execute more complex operations that require more than 2300 gas. However, like send, call does not automatically revert the transaction if the transfer fails. Instead, it returns false. So you should always check the result and handle the failure case manually. Also, because call forwards all remaining gas, it can potentially enable re-entrancy attacks if not used carefully

// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract SendEther { function sendEth(address payable _to) public payable { // Just forward the ETH received in this payable function // to the given address uint amountToSend = msg.value; // call returns a bool value specifying success or failure (bool success, bytes memory data) = _to.call{value: msg.value}(""); require(success == true, "Failed to send ETH"); } }


  1. Using the transfer function


    The transfer function also sends a specified amount of Ether to an address. Unlike send or call, transfer automatically throws an exception and reverts the transaction if the transfer fails. This makes transfer safer and easier to use because you do not need to manually check the result or handle the failure case. However, like send, transfer also only provides a stipend of 2300 gas for execution



// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract SendEther { function sendEth(address payable _to) public payable { // Just forward the ETH received in this payable function // to the given address uint amountToSend = msg.value; // Use the transfer method to send the ETH. _to.transfer(msg.value); } }


By using the .transfer method, we can make the code simpler and more straightforward. The .transfer method is also considered safer because it helps to avoid potential reentrancy attacks.

NOTE: When using .transfer, ensure you know that it will only forwards 2300 gas, thereby preventing reentrancy attacks. This might not be sufficient gas if the receiver contract needs to perform certain operations.

What is the key difference between .send(), .transfer(), and .call(){} methods for sending Ether in Solidity?

  • .send() and .transfer() methods are high-level functions, whereas .call(){} is a low-level function.

  • send() and .transfer() methods return a success status, whereas .call() method reverts transactions if they fail.

  • .send() and .call() methods are low-level functions, provide a gas stipend of 2300, and revert transactions if they fail. .transfer() method is a high-level function.

  • .send() and .call() return a boolean value indicating success and are low-level functions. .transfer() is a high-level function that throws an error if it fails.


Receiving ETH

If you are receiving ETH in an Externally Owned Account (EOA) i.e. an account controlled by a private key (like MetaMask) - you do not need to do anything special as all such accounts can automatically accept all ETH transfers.


However, if you are writing a contract that you want to be able to receive ETH transfers directly, you must have at least one of the functions below:

  • receive() external payable

  • fallback() external payable


receive() is called if msg.data is an empty value, and fallback() is used otherwise.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract ReceiveEther { /* Which function is called, fallback() or receive()? send Ether | msg.data is empty? / \ yes no / \ receive() exists? fallback() / \ yes no / \ receive() fallback() */ // Function to receive Ether. msg.data must be empty receive() external payable {} // Fallback function is called when msg.data is not empty fallback() external payable {} function getBalance() public view returns (uint) { return address(this).balance; } }


Calling Other Contracts

Contracts can call other contracts by just calling functions on an instance of the other contract like A.foo(x, y, z). To do so, you must have an interface for A which tells your contract which functions exist. Interfaces in Solidity behave like header files, and serve similar purposes to the ABI we have been using when calling contracts from the frontend. This allows a contract to know how to encode and decode function arguments and return values for calling external contracts.


💡

NOTE: Interfaces you use do not need to be extensive. i.e. they do not need to necessarily contain all the functions that exist in the external contract - only those which you might be calling at some point.


Assume there is an external ERC20 contract, and we are interested in calling the balanceOf function to check the balance of a given address from our contract.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; interface MinimalERC20 { // Just include the functions we are interested in // in the interface function balanceOf(address account) external view returns (uint256); } contract MyContract { MinimalERC20 externalContract; constructor(address _externalContract) { // Initialize a MinimalERC20 contract instance externalContract = MinimalERC20(_externalContract); } function mustHaveSomeBalance() public { // Require that the caller of this transaction has a non-zero // balance of tokens in the external ERC20 contract uint balance = externalContract.balanceOf(msg.sender); require(balance > 0, "You don't own any tokens of external contract"); } }


Importing Contracts

To maintain code readability, you can split your Solidity code over multiple files. Assume we have a folder structure that looks like this:

├── Import.sol └── Foo.sol


where Foo.sol is

// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Foo { string public name = "Foo"; }


We can import Foo and use it in Import.sol as such

// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; // import Foo.sol from current directory import "./Foo.sol"; contract Import { // Initialize Foo.sol Foo public foo = new Foo(); // Test Foo.sol by getting it's name. function getFooName() public view returns (string memory) { return foo.name(); } }


💡

NOTE: When we use Hardhat, we can also install contracts as node modules through npm, and then import contracts from the node_modules folder. These also count as local imports, as technically when you install a package you are downloading the contracts to your local machine.


Libraries

Libraries are similar to contracts in Solidity, with a few limitations. Libraries cannot contain any state variables, and cannot transfer ETH. Additionally, libraries are only deployed once to the network, which means if you are using a library published by someone else, you do not need to pay gas for it when you deploy your code to the network as Ethereum will understand that that library was already deployed in the past by someone else.


Typically, libraries are used to add helper functions to your contracts. An extremely commonly used library in Solidity world is SafeMath - which ensures that mathematical operations do not cause an integer underflow or overflow.


// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; library SafeMath { function add(uint x, uint y) internal pure returns (uint) { uint z = x + y; // If z overflowed, throw an error require(z >= x, "uint overflow"); return z; } } contract TestSafeMath { function testAdd(uint x, uint y) public pure returns (uint) { return SafeMath.add(x, y); } }


Using a well-known library is cheaper than writing the same code directly in your contract?

  • Yes, because you don't pay gas on it's deployment

  • No, it costs the same amount of gas


Congratulations!

Congrats, you have reached the end of the lesson!


If you have any doubts or just want to celebrate your success, hop in the Discord Server and say Hi, we will be there for you!


*You must be signed in to submit quiz
You must be signed in to post comments
User avatar

Living-roomBoondoggle

·

last month

Wawu

0
User avatar

ConventionalRiding

·

3 months ago

This is lovely education

0
User avatar

Babak Fa

·

5 months ago

Nice

0
User avatar

James Ononiwu

·

5 months ago

Thanks for the lesson, it was straight to the point.

0
User avatar

Sagar Singh

·

5 months ago

this lesson was heavy, but rewarding in the end.

0
BUGG Logo