Solidity Function Calls – Internal and External

4.5/5 - (2 votes)

In this article, we’ll take a closer look at function calls in general, specifically internal function calls in Solidity. It’s part of our long-standing tradition to make this (and other) articles a faithful companion and a supplement of the official Solidity documentation.

You can watch my explainer video on the topic here—I’ll talk about function calls in the second part of the video:

Solidity Control Structures and Function Calls

Function Calls

Looking from the perspective of a function caller, i.e. a contract, we have two ways of calling functions: via internal (direct) and external function calls.

  • By using an internal function call, a function is directly referred to by the function name (signature).
  • In contrast, an external function call refers to both the contract address and the function name (signature).

Internal Function Calls

πŸ’‘ Reminder: A function signature is made of the function name, arguments (parameters), and, depending on the programming language, the return type.

In computer science and programming, recursion is a process where a function calls itself directly or indirectly (both situations are shown in the example below).

A recursive algorithm is a natural approach to a class of problems, such as Towers of Hanoi, tree and graph traversals, and many similar problems.

Any recursive algorithm revolves around the singular principle: calling the same function on smaller subsets of the original problem, solving the problem (base case), and then returning to higher levels (recursive case) and repeating the approach.

Recursion Example

A recursive function call is also considered an internal function call, as we’ll show in the example taken from official Solidity documentation:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.22 <0.9.0;

// This will report a warning
contract C {
    function g(uint a) public pure returns (uint ret) { return a + f(); }
    function f() internal pure returns (uint ret) { return g(7) + f(); }
}

This is just a conceptual example as it makes no sense to use recursion without a base case.

Here, function f() attempts to return the sum of the results of function calls g(7) and f(), while function g(7) attempts to return the sum of 7 and the result of function call f(), and so indefinitely (at least in theory).

A recursion code has two parts: a base case and a recursive case.

  • A base case is often the simplest, but always the most important part of recursion because it checks the recursion-stopping condition and prevents further recursive calls if the condition has been met.
  • A recursive case is the part of the code where the function calls itself. It’s a direct call in a basic case of recursion, or an indirect call in a more complex call hierarchy, as in our example above: g(7) β†’ f() β†’ g(7) and f() β†’ …

⚑ Warning: regardless of the programming language, when coding a recursion, always code the base case first.

In our recursion example, the EVM implements these function calls as relatively inexpensive jumps without clearing the current memory.

Because of that, memory references are simply passed from one internal function call to another, making the entire process very efficient and fast.

However, internal function calls can be used only inside a contract, i.e. inter-contract internal function calls are not possible.

πŸ—’οΈ Note: In practice, Solidity has only 1024 stack slots available, and since every internal function call needs at least one stack slot, the maximum recursion depth cannot exceed 1024 levels. Therefore, the best approach in Solidity is to avoid recursive implementations.

External Function Calls

External Function Calls

As mentioned in the introductory part of the section, besides calling functions by their name, we can also call functions by their contract address and their function name.

An important difference between the two is that external function calls don’t work by doing jumps in memory like internal function calls. Instead, external function calls use message calls.

πŸ’‘ Reminder: A message call in Solidity is an event during which a message consisting of a sender, a recipient, a payload, an Ether value, and an amount of Gas is transferred from the sender to the receiver.

For instance, if we have a contact instance c and a function g(...) which is a member of contract c, an external call from the contract c to function g(...) has two possible variants:

It can be expressed as this.g(10) and c.g(10). These two variants are equivalent because, looking at contract c, the contract address stored in the variable c is equivalent to the member variable this.

⚑ Warning: The keyword this refers to the contract itself from the outside, or in other words, it enables a contract to outrospect. An unpopular analogy in human behavior is a person referring to herself in the third person. A contract can outrospect itself only if it already exists, but since a contract exists only after its constructor completed the execution, the keyword this couldn’t be used inside the contract constructor.

There is no alternative way of calling other contract functions besides making an external call, and for that, function arguments will have to be copied to the recipient’s memory.

This is in clear contrast to internal function calls, where all the action happens in the same memory, and only the references to arguments are passed on.

Friend Analogy External Function Call

Think of it as when two friends discuss a book: if they both read it, they share the context (memory) of the discussion.

However, if only one friend read the book and asks the other friend his opinion (a function call), he would have to give the book (copy the function arguments) to his friend to read and give the answer.

In a sense, this would be analogous to an external function call.

Example External Function Call

The best way to learn how it’s done is hands-on, i.e., by taking a look at an example:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

contract InfoFeed {
    function info() public payable returns (uint ret) { return 42; }
}

contract Consumer {
    InfoFeed feed;
    function setFeed(InfoFeed addr) public { feed = addr; }
    function callFeed() public { feed.info{value: 10, gas: 800}(); }
}

In the example, the Consumer contract sets a reference feed to an existing contract InfoFeed and is able to call its info() function as feed.info{value: 10, gas: 800}().

⚑ Warning: While calling the info() function, special options also specified the amount of Wei and Gas sent with the message call. Solidity authors warn that such an explicit approach is not recommended, since the Gas cost of opcodes can change in the future, and the values would remain hard-coded. Also, Wei sent to the contract is added to the total balance of the target contract, such as by making an external call to a function declared as payable.

Transaction

A transaction is a consistent transition of a contract from one state to another state, e.g. when a state variable is assigned with a different value.

If an external function call is invoked during an ongoing transaction, it will not create its own transaction, but remain a part of the current transaction.

Although explicitly setting special options is not a recommended approach, it is possible. Therefore, we should focus on a seemingly trivial detail: feed.info{value: 10, gas: 800}().

Note the closing parentheses at the end mean that these special values are set locally and applied to this specific function call.

If we just set the special options beforehand and then do the function call, the special options will not apply to the function call:

feed.info{value: 10, gas: 800};
feed.info();

Instead, the special options will be set locally but also become lost before the function call feed.info().

The EVM always treats a call to a non-existing contract as successful. There are two approaches to account for this “careless” behavior by checking if the function call is actually sent to a non-existing contract.

Approach 1

The first approach applies to high-level calls. If the return data is expected to be available immediately after the function call, Solidity will utilize the extcodesize opcode to check the contract code size prior to deciding on executing the function call.

If the opcode result shows that the contract doesn’t exist, i.e. it doesn’t contain the contract code, an exception is thrown and the function call is prevented.

πŸ—’οΈ Note: Function calls such as <address>.call(...), <address>.delegatecall(...), and <address>.staticcall(...) are considered low-level calls.

Approach 2

The second approach applies to low-level calls: if the return data may be decoded after the function call, the function call is allowed, but the ABI decoder will later have to conclude if the return data is valid, and by extension if the contract is existing.

Generally, the extcodesize check is skipped when using low-level ABI calls operating on addresses, instead of contract instances, such as in the case of <address>.call(bytes memory) returns (bool, bytes memory).

We have discussed this in more depth in the “Members of Address Types” section in one of the previous articles.

πŸ’‘ Reminder: Precompiled contracts are placed on special addresses (currently 1 through 8). These addresses don’t contain the contract’s code; instead, the behavior of precompiled contracts is implemented in the EVM execution environment. Therefore, precompiled contract’s code size at its address is 0, and this is also reported in the extcodesize check (hence the following note).

πŸ—’οΈ Note: High-level calls are calls other than <address>.call(...), <address>.delegatecall(...), and <address>.staticcall(...). When we use high-level calls to precompiled contracts, the compiler will consider precompiled contracts as non-existing, regardless of the fact they can execute the code and return the data. That’s the motivation for being especially cautious when using high-level calls to precompiled contracts.

Exceptions are propagated to function calls when the called contract throws an exception or runs out of gas.

⚑ Warning: In some situations, we won’t be familiar with a remotely called contract’s source code. In that sense, interacting with such a contract could represent a security risk or even a potential danger. Specifically, there is a possibility that a remote contract will make a call into our contract, get the result and then repeat the call using its callback/fallback function, even before our contract managed to change its state. This can lead to an uncontrolled change in our contract state and is known as a reentrancy exploit. A good design pattern to alleviate such cases is to organize our functions in a way that we first apply any changes to our contract’s state variables and finalize the transaction. Only after that, are we ready to make or take a function call to/from a remote contract.

🌍 Recommended Tutorial: Top 8 Scary Smart Contract Hacks

Summary

We found out that there are two types of function calls: internal and external ones.

Then we continued with a more in-depth view of internal function calls and related details.

We also learned about recursion and showed a dysfunctional recursion (anti)example.

Then, we went through the nuts and bolts of external function calls.

We learned about the possible ways of making an external function call to the contract itself (an outrospective call).

Then we directed our focus on external function calls to other contracts, described the mechanics behind the calls, and gave several crucial notes, warnings, and recommendations regarding the right approach to using external function calls.

What’s Next?

This tutorial is part of our extended Solidity documentation with videos and more accessible examples and explanations. You can navigate the series here (all links open in a new tab):