Mastering Solidity: Control Structures And Error Handling

Rosario Borgesi
Coinmonks
19 min readMay 11, 2024

--

Solidity, a statically-typed and contract-centric high-level language, is the cornerstone for crafting smart contracts on the Ethereum blockchain. Mastery of control structures and error handling is essential to develop top-tier smart contracts, ensuring robust and efficient decentralized applications.

In this comprehensive guide, we delve into the fundamental control structures of Solidity, including if, else, while, do-while, for, break, continue, and return.

Additionally, we’ll unravel the mechanisms of error handling in Solidity, covering revert, assert, require, as well as the Error and Panic exceptions, complemented by the try-catch block.

Through a series of practical demonstrations, you’ll gain hands-on experience to effectively navigate these concepts. Whether you’re just starting out or are an experienced Solidity developer, this article aims to enhance your understanding and refine your skills.

You can also watch the video here.

Table Of Contents

· Table Of Contents
· Control Structures
If
Else
While
How to set up your local blockchain to use console.log
Do-While
For
Break
Continue
Return
Hands on Demonstration
· Error handling: Assert, Require, Revert and Exceptions
Assert statement and Panic Errors
Require statement and Error
Revert statement
· Try/Catch
· Further Exploration
· Conclusions
· References

Control Structures

Control structures in a programming language are constructs that manipulate the flow of execution. They allow the program to branch in different directions and loop through blocks of code based on certain conditions. Here are the main types of control structures:

  1. Sequential Control Structure: This is the default control structure where instructions are executed one after the other in the order they appear in the script.
  2. Selection or Conditional Control Structure: This allows one set of statements to be executed if a condition is true and another set of statements to be executed if the condition is false. Examples include if, and if-else statements.
  3. Iteration or Loop Control Structure: This allows a set of instructions to be repeatedly executed until a certain condition is met. Examples include for, while, and do-while loops.
  4. Jump Control Structure: This alters the execution flow by moving control to another part of the program. Examples include break, continue, and return.

These control structures are fundamental to programming and are found in most, if not all, programming languages including Solidity.

Solidity also supports exception handling in the form of try/catchstatements, but only for external function calls and contract creation calls. Errors can be created using the revert statement.

If

The if statement is used to specify a block of code to be executed if a specified condition is true. For example:

if (x > y) {
// code to be executed if x is greater than y
}

Else

The else statement is used to specify a block of code to be executed if the same condition is false. For example:

if (x > y) {
// code to be executed if x is greater than y
} else {
// code to be executed if x is not greater than y
}

While

The while statement is used to loop through a block of code as long as the specified condition is true. For example:

while (x < 10) {
// code to be executed while x is less than 10
x++;
}

The while loop will executed the code inside the block {} as long as the condition x < 10 is true. At the end of each iteration the variable x is auto-incremented by 1 with the ++ operator by one. Once x becomes greater than 10 the code after the while block will be executed.

To make a practical test we could print the variable x in the console:

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

import "hardhat/console.sol";

contract MyContract {
function test() public view {
uint x = 0;
while (x < 5) {
console.log(x);
x++;
}
}
}

The previous code would output:

console.log:
0
1
2
3
4

How to set up your local blockchain to use console.log

Please note that in the previous contract we made use of the functionconsole.log provided by the Hardhat contract console.solto print in the the console of the Hardhat local node. In fact as you can guess since in Ethereum the code is executed in a decentralized manner printing in the console is not as straightfoward as in other programming languages.

If you want to try this code you need to create a new Hardhat project with the following steps:

npm init -y
npm install --save-dev hardhat
npx hardhat init

Then:

  • Choose Create an empty hardhat.config.js
  • In the file hardhat.config.js change the Solidity version to: 0.8.25

Finally you need to start the Hardhat local blockchain with npx hardhat node.

Now if you would like to use Remix to test this contract you should choose Dev-Hardhat Provider as environment in the Deploy & run transactions section, so that Remix can communicate with your local blockchain provided by Hardhat.

Settings required to connect Remix with the local Hardhat node

Do-While

The do statement is used in conjunction with while to create a do-while loop, which is a variant of the while loop. This loop will execute the block of code once, before checking if the condition is true, then it will repeat the loop as long as the condition is true. For example:

do {
// code to be executed
x++;
} while (x < 10);

The main difference between a whileloop and a do-whileloop is that the first could be never executed if the condition is not satisfied while the second is executed at least one time.

For

The for statement is used to create a for loop, which consists of three parts: initialization, condition, and increment/decrement. For example:

for (uint i = 0; i < 10; i++) {
// code to be executed
}
  • In the first expression we initilize the variable that will count the number of iteration uint i = 0
  • In the second expression we declare the condition that must hold true for the next iteration to be executed i < 10
  • In the third expression we express the update (increment or decrement) of the iteration variable i

To make a pratical example we could print the variable i in the console:

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

import "hardhat/console.sol";

contract MyContract {
function test() public view {
for (uint i = 0; i < 5; i++) {
console.log(i);
}
}
}

The previous code would print:

console.log:
0
1
2
3
4

The for loop is similar to the while loop, and the following two examples are equivalent:

uint i= 0;
while (i < 10) {
// code to be executed while x is less than 10
i++;
}

// is equivalent to

for (uint i = 0; i < 10; i++) {
// code to be executed
}

When should we choose a for loop over a while loop?

  • For loop: Suitable for a known number of iterations or when looping over ranges.
  • While loop: Useful when the number of iterations is not known in advance or based on a condition.

Break

The break statement is used to stop the execution of the loop immediately and transfer control to the next statement following the loop. For example:

for (uint i = 0; i < 10; i++) {
if (i == 5) {
break;
}
// code to be executed
}

Let’s make also a practical test by executing the following code:

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

import "hardhat/console.sol";

contract MyContract {
function test() public view {
for (uint i = 0; i < 10; i++) {
if (i == 5) {
break;
}
console.log(i);
}
}
}

The previous code will print:

console.log:
0
1
2
3
4

Note that the loop stopped at i == 5because of the breakstatement.

Continue

The continue statement is used to skip the rest of the code inside the current iteration of the loop and continue with the next iteration. For example:

for (uint i = 0; i < 10; i++) {
if (i == 5) {
continue;
}
// code to be executed
}

Let’s make also a practical test by executing the following code:

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

import "hardhat/console.sol";

contract MyContract {
function test() public view {
for (uint i = 0; i < 10; i++) {
if (i == 5) {
continue;
}
console.log(i);
}
}
}

It will print:

0
1
2
3
4
6
7
8
9

You can see that for iequal to 5 nothing was printed in the console because the continuestatement jumped to the next iteration.

Return

The return statement is used to specify the value that a function should return. For example:

function add(uint x, uint y) public pure returns (uint) {
uint sum = x + y;
return sum;
}

Hands on Demonstration

The following code demonstrates all the statements that we have discussed before. For each function it has been created a corresponding function to test it. For instance: the isPositive demonstrates theif-elsestatement: if the input number x is positive returnstrue, otherwisefalse. The function testIsPositive is intended to test the function isPositiveby making different assetions, using the assert statement: if the assertion is false the function will revert otherwise the function will be executed without errors.

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

contract MyContract {
// Demonstrating the if-else statement
function isPositive(int x) public pure returns (bool) {
if (x > 0) {
return true;
} else {
return false;
}
}

// Demonstrating the while statement
function recursiveSum(uint x) public pure returns (uint) {
uint i = 0;
uint sum = 0;
while (i <= x) {
sum += i; // equivalent to sum = sum + i;
i++;
}
return sum;
}

// Demonstrating the do-while statement
function sumBackwards(uint start) public pure returns (uint) {
if (start == 0) {
return 0;
}
uint i = start;
uint sum = start;
do {
i--;
sum += i; // equivalent to sum = sum + i;
} while (i > 0);
return sum;
}

// Demonstrating the for statement
function factorialFuntion(uint x) public pure returns (uint) {
uint factorial = 1;
for (uint i = 1; i <= x; i++){
factorial *= i; // equivalent to factorial = factorial * i;
}
return factorial;
}

// Demonstrating the break statement
function stopCountAtFive(uint x) public pure returns (uint) {
uint i = 0;
while (i < x) {
if (i == 5) {
break;
}
i++;
}
return i;
}

// Demonstrating the continue statement
function skipSumAtThree(uint x) public pure returns (uint) {
uint i = 0;
uint sum = 0;
while (i < x) {
i++;
if (i == 3) {
continue;
}
sum += i; // equivalent to sum = sum + i;
}
return sum;
}

// Testing the isPositive function
function testIsPositive() public pure {
assert(isPositive(5) == true);
assert(isPositive(-5) == false);
}

// Testing the recursiveSum function
function testRecursiveSum() public pure {
assert(recursiveSum(0) == 0);
assert(recursiveSum(1) == 1);
assert(recursiveSum(2) == 3);
assert(recursiveSum(3) == 6);
assert(recursiveSum(4) == 10);
assert(recursiveSum(5) == 15);
}

// Testing the factorialFunction function
function testFactorialFunction() public pure {
assert(factorialFuntion(0) == 1);
assert(factorialFuntion(1) == 1);
assert(factorialFuntion(2) == 2);
assert(factorialFuntion(3) == 6);
assert(factorialFuntion(4) == 24);
assert(factorialFuntion(5) == 120);
}

// Testing the sumBackwards function
function testSumBackwards() public pure {
assert(sumBackwards(0) == 0);
assert(sumBackwards(1) == 1);
assert(sumBackwards(2) == 3);
assert(sumBackwards(3) == 6);
assert(sumBackwards(4) == 10);
assert(sumBackwards(5) == 15);
}

// Testing the stopCountAtFive function
function testStopCountAtFive() public pure {
assert(stopCountAtFive(0) == 0);
assert(stopCountAtFive(1) == 1);
assert(stopCountAtFive(2) == 2);
assert(stopCountAtFive(3) == 3);
assert(stopCountAtFive(4) == 4);
assert(stopCountAtFive(5) == 5);
assert(stopCountAtFive(6) == 5);
}

// Testing the skipSumAtThree function
function testSkipSumAtThree() public pure {
assert(skipSumAtThree(0) == 0);
assert(skipSumAtThree(1) == 1);
assert(skipSumAtThree(2) == 3);
assert(skipSumAtThree(3) == 3);
assert(skipSumAtThree(4) == 7);
assert(skipSumAtThree(5) == 12);
}
}

Error handling: Assert, Require, Revert and Exceptions

In Solidity, exceptions are used for error handling. They can occur at both compile time and runtime.

When the Solidity code is compiled to byte code, a syntax error check happens at compile-time. If there are any errors, the compiler will not compile the code until the errors are resolved.

Runtime errors are more challenging to catch and mainly occur while executing the contracts. Some of the runtime errors include out-of-gas error, data type overflow error, divide by zero error, array-out-of-index error, etc.

Exceptions that occur in a sub-call will automatically propagate upwards (meaning, they are rethrown) unless they are intercepted by a try/catch statement. However, there are exceptions to this rule. The send function and the low-level functions call, delegatecall, and staticcall behave differently: instead of propagating the exception upwards, they return false as their first return value in case of an exception.

Exceptions have the ability to carry error information, which is returned to the caller as instances of errors. The built-in errors, namely Error(string) and Panic(uint256), are utilized by specific functions: assert, revert and require. The Error is employed for standard error situations, while Panic is used for errors that should not occur in code that is free of bugs.

Assert statement and Panic Errors

The assert function is utilized to verify conditions and it will trigger an exception if the specified condition is not fulfilled. This function generates an error of the Panic(uint256) type.

The assert function should be exclusively used for internal error testing and invariant checking. Code that is functioning correctly should never produce a Panic, even in response to incorrect external input. If a Panic is generated, it indicates a bug in your contract that needs to be addressed.

When a revert is triggered all changes made to the EVM state are reverted.

A Panic exception is generated in the following situations. The error code supplied with the error data indicates the kind of panic:

  1. 0x00: Used for generic compiler inserted panics.
  2. 0x01: If you call assert with an argument that evaluates to false.
  3. 0x11: If an arithmetic operation results in underflow or overflow outside of an unchecked { ... } block.
  4. 0x12; If you divide or modulo by zero (e.g. 5 / 0 or 23 % 0).
  5. 0x21: If you convert a value that is too big or negative into an enum type.
  6. 0x22: If you access a storage byte array that is incorrectly encoded.
  7. 0x31: If you call .pop() on an empty array.
  8. 0x32: If you access an array, bytesN or an array slice at an out-of-bounds or negative index (i.e. x[i] where i >= x.length or i < 0).
  9. 0x41: If you allocate too much memory or create an array that is too large.
  10. 0x51: If you call a zero-initialized variable of internal function type.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract MyContract {

function exampleFunction(uint256 x) public pure returns (uint) {
assert(x > 0);
return x;
}

function divide(uint x, uint y) public pure returns (uint) {
return x / y;
}
}

If we call the exampleFunction with x equal to 0 on Remix, we will get this output:

call to MyContract.exampleFunction
call to MyContract.exampleFunction errored: Error occurred: execution reverted: assert(false).

execution reverted: assert(false)

If we call the divide function with x = 10 and y = 0 on Remix we will get this output:

call to MyContract.divide
call to MyContract.divide errored: Error occurred: execution reverted: division or modulo by zero.

execution reverted: division or modulo by zero

In either cases the execution of the function has been reverted and a Panic Error has been thrown that has caused the Remix console to print the previous logs.

To demonstrate that Solidity will throw a Panic error with a specific error code under certain circumstances, we can make use of the try-catch block, which will be explained later:

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

contract MyContract {
uint[] myArray;

function exampleFunction(uint256 x) external pure returns (uint) {
assert(x > 0);
return x;
}

function popArray() external {
myArray.pop();
}

function divide(uint x, uint y) public pure returns (uint) {
return x / y;
}

// Demonstrating that a false assertion throws a Panic error of code 0x01
function testExampleFunction() public view {
try this.exampleFunction(0) {
} catch Panic(uint errorCode) {
assert(errorCode == 0x01);
return;
}
assert(false);
}

// Demonstrating that calling .pop on an empty array throws a Panic error of code 0x31
function testPopArray() public {
try this.popArray() {
} catch Panic(uint errorCode) {
assert(errorCode == 0x31);
return;
}
assert(false);
}

// Demonstrating that dividing by zero throws a Panic error of code 0x12
function testDivide() public view {
try this.divide(10, 0) {
} catch Panic(uint errorCode) {
assert(errorCode == 0x12);
return;
}
assert(false);
}
}

In this code we have defined the exampleFunctionthat raises a Panicerror if it is called with an input greater than zero: assert(x > 0). In the test function testExampleFunctionwe are calling the exampleFunctionwith 0 as input, and the catchstatement is execute only if the exampleFunctionraises a Panicerror. Inside the test we are testing that the errorCodeof the Panicerror is equal to 0x01with assert(errorCode == 0x01)then we return. If the function should not raise a Panicerror, the test would fail because it would be executed assert(false)outside the catch block.

In the function testPopArraywe are demonstrating that calling .pop on an empty array raises a Panicerror of code 0x31. A similar test is performed in the functoin testDividewhere we are testing that dividing a number by zero raises a Panicerror with code 0x12.

Require statement and Error

The require function is used for input validation. It checks whether a certain condition is true, and if it’s not, it reverts all changes made to the state during the current call (like when an exception is thrown in other programming languages) and also provides an error message.

The require function provides three overloads:

  1. require(bool) which will revert without any data (not even an error selector).
  2. require(bool, string) which will revert with an Error(string).
  3. require(bool, error) which will revert with the custom, user supplied error provided as the second argument.

An Error(string) exception (or an exception without data) is generated by the compiler in the following situations:

  1. Calling require(x) where x evaluates to false.
  2. If you use revert() or revert("description").
  3. If you perform an external function call targeting a contract that contains no code.
  4. If your contract receives Ether via a public function without payable modifier (including the constructor and the fallback function).
  5. If your contract receives Ether via a public getter function.

For the following cases, the error data from the external call (if provided) is forwarded. This means that it can either cause an Error or a Panic (or whatever else was given):

  1. If a .transfer() fails.
  2. If you call a function via a message call but it does not finish properly (i.e., it runs out of gas, has no matching function, or throws an exception itself), except when a low level operation call, send, delegatecall, callcode or staticcall is used. The low level operations never throw exceptions but indicate failures by returning false.
  3. If you create a contract using the new keyword but the contract creation does not finish properly.

You can optionally provide a message string or a custom error to require, but not to assert. If you do not provide a string or custom error argument to require, it will revert with empty error data, not even including the error selector.

However, in our code, it is advisable to throw errors that contain a meaningful message. This can help us understand why the execution was interrupted.

Similar to the assert function, when a revert is triggered internally in Solidity, all changes made to the EVM state are reverted.

Additionally we can catch the Errors thrown by a revert statement by means of a try/catchblock.

To demonstrate the require function we can use the following code:

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

contract MyContract {
function divide(uint x, uint y) public pure returns (uint) {
require(y > 0, "The denominator must be greater than zero");
return x / y;
}
}

This time we have put at the beginning of the function dividea require statement to check the input of our function. In particular if we pass 0 as y the require statement will raise an exception and the function will stop its execution.

For instance if we call the divide function with x = 10 and y = 0 we will see this log in the Remix console:

call to MyContract.divide
call to MyContract.divide errored: Error occurred: execution reverted: The denominator must be greater than zero.

execution reverted: The denominator must be greater than zero

To prove that the revert statment is throwing an error of type Error and not for example a Panic we can make use of a try-catch block:

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

contract MyContract {

function divide(uint x, uint y) public pure returns (uint) {
require(y > 0, "The denominator must be greater than zero");
return x / y;
}

// Demonstrating that divide throws an Error
function testDivide() public view {
try this.divide(10, 0) {
} catch Error(string memory message) {
assert(compareStrings(message, "The denominator must be greater than zero"));
return;
}
assert(false);
}

// Compares two strings and returns true if they are equal
function compareStrings(string memory str1, string memory str2) public pure returns (bool) {
return keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2));
}
}

In the catchblock we are catching an error of type Errorand we are testing that the message that the Errorcarries is exactly ‘The denominator must be greater than zero’.

Revert statement

To force a direct revert during a function execution we can use the revert statement. It takes a custom error as direct argument without parentheses:

revert CustomError(arg1, arg2);

For backward-compatibility reasons, there is also the revert() function, which uses parentheses and accepts a string:

revert(); revert(“description”);

The error data will be passed back to the caller and can be caught there. Using revert() causes a revert without any error data while revert("description") will create an Error(string) error.

Using a custom error instance will usually be much cheaper than a string description, because you can use the name of the error to describe it, which is encoded in only four bytes.

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

contract MyContract {
error NumberNotValid();

function divide(uint x, uint y) public pure returns (uint) {
if (y == 0) {
revert NumberNotValid();
}
return x / y;
}
}

The previous code demonstrates how the revertstatement throws a custom error: if y equals 0 it will be thrown a NumberNotValidErrorerror.
In fact if we call the dividefunction with argument y = 10 and x = 0, the Remix console will output:

call to MyContract.divide errored: Error occurred: execution reverted.

execution reverted
The transaction has been reverted to the initial state.
Error provided by the contract:
NumberNotValid
Parameters:
{}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract MyContract {
function divide(uint x, uint y) public pure returns (uint) {
if (y == 0) {
revert("The denominator must be greater than zero");
}
return x / y;
}

// Demonstrating that divide throws an Error
function testDivide() public view {
try this.divide(10, 0) {
} catch Error(string memory message) {
assert(compareStrings(message, "The denominator must be greater than zero"));
return;
}
assert(false);
}

// Compares two strings and returns true if they are equal
function compareStrings(string memory str1, string memory str2) public pure returns (bool) {
return keccak256(abi.encodePacked(str1)) == keccak256(abi.encodePacked(str2));
}
}

In this second example the revertfunction will throw an error of type Error.As the testDividefunction demonstrates the revertfunction is equivalent to the requirestatement we have used earlier:

require(y > 0, "The denominator must be greater than zero");

// is equivalent to:

if (y == 0) {
revert("The denominator must be greater than zero");
}

However, we can catch errors of type Error or Panic in a try block, but we cannot specifically catch custom errors.

Try/Catch

As we have seen in the previous example a failure in an external call can be caught using a try/catch statement:

try someExternalContract.someFunction() {
// some code here
} catch Error(string memory reason) {
// This block will catch an Error thrown by the external call
} catch Panic(uint errorCode) {
// This block will catch a Panic error thrown by the external call
} catch (bytes memory lowLevelData) {
// is executed if the error signature do not match the other clauses and provides low-level error data
} catch {
// is executed if the error signature do not match the other clauses but no low-level error data is provided
}

The try keyword has to be followed by an expression representing an external function call or a contract creation (new ContractName()). Errors inside the expression are not caught (for example if it is a complex expression that also involves internal function calls), only a revert happening inside the external call itself. The returns part (which is optional) that follows declares return variables matching the types returned by the external call. In case there was no error, these variables are assigned and the contract’s execution continues inside the first success block. If the end of the success block is reached, execution continues after the catch blocks.

Solidity supports different kinds of catch blocks depending on the type of error:

  • catch Error(string memory reason) { ... }: This catch clause is executed if the error was caused by revert("reasonString") or require(false, "reasonString") (or an internal error that causes such an exception).
  • catch Panic(uint errorCode) { ... }: If the error was caused by a panic, i.e. by a failing assert, division by zero, invalid array access, arithmetic overflow and others, this catch clause will be run.
  • catch (bytes memory lowLevelData) { ... }: This clause is executed if the error signature does not match any other clause, if there was an error while decoding the error message, or if no error data was provided with the exception. The declared variable provides access to the low-level error data in that case.
  • catch { ... }: If you are not interested in the error data, you can just use catch { ... } (even as the only catch clause) instead of the previous clause.

In order to catch all error cases, you have to have at least the clause catch { ...} or the clause catch (bytes memory lowLevelData) { ... }.

In the following example we demonstrate how it is possible to catch a custom error with the clause catch (bytes memory lowLevelData). In fact the catch clauses with Panicor Errordo not catch the error since they have the assert(false)statement inside and if they would catch the error the test would fail. Instead the error is only catched by the the last clause catch (bytes memory lowLevelData)and we can also retrieve the bytes associeted to the error.

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

contract MyContract {
error NumberNotValid();

function divide(uint x, uint y) public pure returns (uint) {
if (y == 0) {
revert NumberNotValid();
}
return x / y;
}

// demonstrates how to catch a custom error with catch (bytes memory lowLevelData)
function testDivide() public view {
try this.divide(10, 0) {
} catch Error(string memory message) {
assert(false);
return;
} catch Panic(uint errorCode) {
assert(false);
return;
} catch (bytes memory lowLevelData) {
assert(lowLevelData.length != 0);
return;
}
assert(false);
}
}

In the following example we demonstrate how to catch a custom error with a simple catch clause.

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

contract MyContract {
error NumberNotValid();

function divide(uint x, uint y) public pure returns (uint) {
if (y == 0) {
revert NumberNotValid();
}
return x / y;
}

// demonstrates how to catch a custom error with catch (bytes memory lowLevelData)
function testDivide() public view {
try this.divide(10, 0) {
} catch Error(string memory message) {
assert(false);
return;
} catch Panic(uint errorCode) {
assert(false);
return;
} catch {
assert(true);
return;
}
assert(false);
}
}

Further Exploration

For those eager to dive into coding Solidity smart contracts, I recommend exploring the following resources:

For those who want to understand more about how the blockchain works I can recommend the following article:

Conclusions

I trust that this article has served as a valuable resource in your exploration of Solidity’s control structures and error handling. Should you have any inquiries or come across any discrepancies, I encourage you to share your thoughts in the comments section. Here’s to your coding journey, may it be filled with discovery and success!

References

--

--