Back to blogs
Written by
Tilak Madichetti
Published on
June 5, 2025

Solidity: A Guide to Internal Call Graphs for Static Analysis

Learn how to build and traverse Solidity call graphs for static analysis, vulnerability detection, and smarter contract development using Aderyn.

Table of Contents

Introduction

Solidity contracts can be deceptively simple on the surface. Underneath, they form deep chains of function calls, inheritance paths, and modifiers that are hard to follow without the right tools. 

A call graph gives shape to that complexity. It shows how transactions flow through the smart contract, helping developers and auditors trace execution paths clearly and efficiently.

This guide teaches you how to build them.

What is a call graph?

A call graph is a diagram that shows how functions call and interact with each other. Each node represents a function or subroutine, and each arrow represents a call from one function to another.

Here’s a simple example:

A call graph showing Function A calling Function B and Function C, and Function B also calling Function C.

In this graph:

  • Function A calls both Function B and Function C.
  • Function B calls Function C.
  • Function C does not call any other functions.

Call graphs are especially useful for static analysis. They help us understand how data and transactions move through a smart contract, and let us zoom into any function and see what might be involved in its execution path.

In Solidity, functions can be either internal or external. Internal calls happen within the same contract or across inherited contracts, regardless of visibility (public, internal, etc.). We ignore external calls (those that can send calldata to other contracts) because we’re only interested in how functions interact within the contract and its inheritance tree.

Although the implementation we describe here is drawn from Aderyn, the concepts apply to any Solidity smart contract.

Preparing call graphs

The first step is to extract all deployable contracts in a project, that is, fully implemented, non-abstract contracts.

We recommend generating a separate call graph for each contract. If a function is reused across contracts, it will show up in multiple graphs. That duplication is intentional and necessary, since the same function can behave differently depending on where it’s called from. (We explain why in the Router and lookup sections.)

For each contract, start by identifying the entry points: public and external functions defined in the contract or inherited from parent contracts. These are inserted into a worklist.

A worklist is a continuously updated list that contains all the subtasks that the algorithm must complete. These can be added to or removed from the list during the execution of the algorithm itself. Typically the algorithm runs until the worklist becomes empty.

We go through each function in the worklist step by step, following the chain of calls within that function as far as it goes before moving on to the next one. This helps us build the full picture of how all functions connect. 

Here's the process in pseudocode:

// For each deployable contract C:

Visited = {}
Graph = {}

// initialized with list of entrypoint functions in C
Worklist = C->Entrypoints() 


while not Worklist->empty():
    Func = Worklist->pop()
    
    // prevent infinite loop in case of recursion
    if Visited[Func]:
        continue
    Visited[Func] = true

    for FuncCall in Func -> ListInternalFunctionCalls():
        TargetFn = ::Router->ResolveFuncCall(C, FuncCall)  // Explained below

 		// store the graph and associate it with contract C
        Graph->AddNodeIfDoesntExist(Func)
        Graph->AddNodeIfDoesntExist(TargetFn)
        Graph->AddEdgeIfDoesntExist(Func, TargetFn)

	 	// for further exploration in the call graph
        Worklist.push(TargetFn)


For every function in the worklist, we extract its internal calls and resolve each one to its definition using the Router (covered in the next section). We then link the calling function to its target function or modifier.

Note that we do the same for modifiers as well in the actual implementation. Each resolved target, whether a function or a modifier, is pushed back into the worklist for further exploration. This continues until the entire reachable call graph from the contract’s entry points has been traversed.

For the actual implementation, see the source code on GitHub.

Router

The router is a key part of the system that resolves internal calls to their definitions based on the context of the base contract.

In the earlier pseudocode, you may recall this line:

TargetFn = ::Router-> ResolveFuncCall(C, FuncCall)


At first glance, it looks like this function takes two arguments: the base contract (C) and the call being resolved (FuncCall). But there’s also a third, implicit input, the location of the said Function Call in the source code. This can be inferred from the abstract syntax tree (AST) node associated with FuncCall.

Why does it matter? To answer that, let’s explore a few exercises.

Exercise 1

contract Grandparent {
    function myFunc() public virtual {}
}

contract Parent1 is Grandparent {
    function p1() public {
        myFunc();
    }
}

contract Parent2 is Grandparent {
    function p2() public {
        myFunc();
    }
}

contract Child is Grandparent, Parent2, Parent1 {
    function myFunc() public override(Grandparent) {}
    function abc() public {
        p1(); 
    }
}


Take a moment to review the code above.

Now look at the p1 and p2 functions. They both call myFunc(), but here’s the real question: Does myFunc() always refer to the version defined in Grandparent?

Answer: No.

Explanation:

If p1() is called directly on an instance of Parent1, or p2() is called on Parent2, then myFunc() resolves to the one defined in Grandparent. But if p1() is called by Child, for example via the abc() function, then myFunc() resolves to the overridden version defined in Child.

The key insight here is this: resolving a function call depends not just on the function name and its parameters, but also on the contract from which the call originates. In other words, the base or initiating contract determines how the call is interpreted.

This is why the router needs to know the base contract to resolve the function call.

Exercise 2

contract Grandparent {
    function myFunc() public virtual {}
}

contract Parent1 is Grandparent {
    function p1() public {
        super.myFunc();
    }
}

contract Parent2 is Grandparent {
    function p2() public {
        super.myFunc();
    }
    function myFunc() public override(Grandparent) virtual {}
}

contract Child is Parent2, Parent1 {
    function myFunc() public override(Grandparent, Parent2) {}
    function abc() public {
        p1(); 
    }
}


Let’s look at the p1() and p2() functions. Both use super.myFunc(). But the real question is: Does super.myFunc() always call the version of myFunc() defined in Grandparent?

Answer: No.

Explanation:

If p1() is called directly from Parent1, then super.myFunc() refers to Grandparent.myFunc() because Grandparent is the next contract in the linearized inheritance order after Parent1.

But if p1() is triggered by Child, for example through a call to abc(), then everything changes. In that case, super.myFunc() inside Parent1 starts the lookup not from Grandparent, but from Parent1’s next contract in Child’s linearized inheritance hierarchy  list —i.e, Parent2. And since Parent2 overrides myFunc(), that version is the one that gets executed.

This behavior is visualized below:

  • Without super, the lookup starts from the base contract - in this case, Child.

  • With super, the lookup starts from the contract positioned next to the contract in which the super call appears, in the initiating contract's linearized inheritance hierarchy   (e.g., Parent1Parent2).


Note
: Linearized inheritance hierarchy is the order in which all the contracts are assembled using the C3 algorithm. Here is a video that explains it.  

The key takeaway: The super keyword doesn’t mean “go to my immediate parent, as defined in the containing contract.” Instead, it means “start the lookup from the contract after me in the inheritance order of the current base contract.” The lookup still occurs within the hierarchy of the initiating (base) contract, but the start-lookup index is shifted.

So, what do we need to resolve a function call accurately?

  1. Base contract: The deployable contract that sets the context to resolve the function call.

  2. Start-lookup index: Where to begin the search in the Base contract’s linearized inheritance hierarchy.

  3. Function Call (AST Node): Provides function name + parameter types + calling location 

In short, function resolution is contextual. The same function call can yield different results depending on where and how it’s triggered.

Exercise 3

contract Grandparent {
    function myFunc() public virtual {}
}

contract Parent1 is Grandparent {
    function p1() public {
        Grandparent.myFunc();
    }
}

contract Parent2 is Grandparent {
    function p2() public  {
        Grandparent.myFunc();
    }
    function myFunc() public override(Grandparent) virtual  {}
}

contract Child is Parent2, Parent1 {   
    function myFunc() public override(Grandparent, Parent2) {}
    function abc() public {
        p1(); 
    }
}

Let’s examine the p1() and p2() functions. Both explicitly call Grandparent.myFunc().

Question: Does this call always resolve to the myFunc() implementation defined in Grandparent?

Answer: Yes

Explanation:

This time, there’s no ambiguity. The call explicitly names the contract: Grandparent.myFunc(). That means the lookup starts and ends with the Grandparent definition, regardless of where the call to p1() or p2()  originates from in the inheritance tree.

One final note: Modifiers are resolved similarly as functions.

Now that we’ve seen how the Router works in different contexts, let’s move on to building the router itself.

The Router data structure

At its core, the Router is a nested mapping that allows us to resolve internal function calls and modifier calls with the full context of a deployable contract. Its structure looks like this:

Mapping[
	// base (or) initiating contract
	NodeID => Mapping[

		// start-lookup index within the base contract's C3 heirarchy
		NodeID => Mapping[
        
       		// Selectorish (function name + parameter type combo)
       		String 
				=>  // Function definition
    			NodeID
						]
					]
]


You can view the implementation to build the router for internal calls here and the lookup process itself here.

Call graph traversal strategies

Now that we've seen how to prepare and visualize a call graph, the next step is understanding how to traverse it effectively, especially for static analysis tasks.

Let’s take a concrete example: identifying a delegatecall inside a loop.

At first, it might seem enough to scan the loop body directly. But that won’t catch everything. If the loop contains function calls, you need to follow those calls and inspect the functions they trigger. This is where the call graph becomes essential.

Here’s how to approach it:

1. Identify the function that contains the loop.

Find all call graphs where this function appears as a node. (Remember: each deployable contract has its own call graph.) Let’s call this Set 1. Then, collect all function call nodes inside the loop body. These are your Set 2.

2. Resolve the calls

Resolve the function calls of Set 2 to their corresponding function definitions in each of the call graphs of Set 1 using the router. Use the resolved functions as starting points for traversal in their respective call graphs.

3. Traverse the graph

From each starting point, perform a depth-first or breadth-first traversal. One significant benefit of using the call graph is that it automatically filters out unreachable code, such as functions that have been overridden or are never executed. If a function weren’t reachable, it wouldn’t have been added to the graph in the first place.

You can explore our implementation of this traversal strategy in Aderyn here.

Conclusion

We’ve implemented these strategies in Aderyn to generate precise call graphs that help vulnerability detectors analyze code more effectively and give developers deeper insight into execution flow. Improving the accuracy of our call graphs led to a 20% boost in detector precision in our internal tests.

If you’re curious about how we handle things at the AST level or just want a deeper dive, feel free to reach out on X. We’re always happy to chat.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.