Skip to main content

Call Tree Decoding

When a smart contract calls other contracts, the execution forms a tree of calls. After simulating a transaction, you can decode this tree to see every function invocation — with resolved names, decoded parameters, and gas usage at each level.

This is essential for understanding complex DeFi transactions where a single user call triggers multiple internal calls across contracts.

How It Works​

The ProgramResultDecoder takes raw execution results and resolves:

  • Function selectors to human-readable names (e.g., 0xa9059cbb to transfer(address,uint256))
  • Input data to decoded parameters with names and types
  • Sub-calls to nested DecodedCall objects forming a tree
  • Call types to Call, DelegateCall, StaticCall, Create, Create2

The decoder needs an IABIInfoStorage to look up ABIs. Without it, calls appear as raw hex data.

Prerequisites​

dotnet add package Nethereum.EVM

Decoding After Execution​

After executing a transaction with TransactionExecutor (see Transaction Simulation), decode the result:

using Nethereum.EVM.Decoding;

var decoder = new ProgramResultDecoder(abiStorage);
var decoded = decoder.Decode(execResult, callInput, chainId);

The decoded.RootCall is the entry point — the function your transaction called. Its InnerCalls list contains all sub-calls made by that function.

Walking the Call Tree​

Each DecodedCall has a list of InnerCalls, forming a recursive tree. Here is how to walk it:

void PrintCallTree(DecodedCall call, int indent = 0)
{
var prefix = new string(' ', indent * 2);
var name = call.GetDisplayName(); // "ContractName.FunctionName" or hex fallback
var value = call.Value > 0 ? $" [{Web3.Convert.FromWei(call.Value)} ETH]" : "";

Console.WriteLine($"{prefix}{call.CallType}: {name}{value} (gas: {call.GasUsed})");

// Print decoded input parameters
if (call.InputParameters != null)
{
foreach (var param in call.InputParameters)
{
Console.WriteLine($"{prefix} {param.Parameter.Name}: {param.Result}");
}
}

// Recurse into sub-calls
if (call.InnerCalls != null)
{
foreach (var inner in call.InnerCalls)
{
PrintCallTree(inner, indent + 1);
}
}
}

PrintCallTree(decoded.RootCall);

For a Uniswap swap, this might output:

Call: UniswapV2Router.swapExactTokensForTokens (gas: 145000)
amountIn: 1000000000000000000
amountOutMin: 990000000
path: [0xC02...DAI, 0xA0b...USDC]
Call: DAI.transferFrom (gas: 35000)
from: 0xYourAddress
to: 0xPairAddress
amount: 1000000000000000000
Call: UniswapV2Pair.swap (gas: 85000)
amount0Out: 0
amount1Out: 995000000
Call: USDC.transfer (gas: 25000)
to: 0xYourAddress
amount: 995000000

Quick Summary with ToHumanReadableString​

For a quick overview without writing a tree walker, use the built-in formatter:

Console.WriteLine(decoded.ToHumanReadableString());

This prints the call tree, events, return values, and revert reason in a structured text format.

Inspecting Individual Calls​

Each DecodedCall provides rich information:

var rootCall = decoded.RootCall;

// Basic info
Console.WriteLine($"From: {rootCall.From}");
Console.WriteLine($"To: {rootCall.To}");
Console.WriteLine($"Function: {rootCall.GetFunctionName()}");
Console.WriteLine($"Signature: {rootCall.GetFunctionSignature()}");
Console.WriteLine($"Call type: {rootCall.CallType}");
Console.WriteLine($"Depth: {rootCall.Depth}");
Console.WriteLine($"Decoded: {rootCall.IsDecoded}");

// If not decoded, raw data is available
if (!rootCall.IsDecoded)
{
Console.WriteLine($"Raw input: {rootCall.RawInput}");
Console.WriteLine($"Raw output: {rootCall.RawOutput}");
}

// Check for reverts at any level
if (rootCall.IsRevert && rootCall.Error != null)
{
Console.WriteLine($"Reverted: {rootCall.Error.GetDisplayMessage()}");
}

IsDecoded is true when the ABI was found and the function signature was matched. When false, RawInput and RawOutput contain the hex-encoded data.

Decoding a Single Call​

You can also decode individual calls without a full execution:

var decoder = new ProgramResultDecoder(abiStorage);
var decodedCall = decoder.DecodeCall(callInput, chainId, depth: 0);

Console.WriteLine($"Function: {decodedCall.GetFunctionName()}");
foreach (var param in decodedCall.InputParameters ?? new List<ParameterOutput>())
{
Console.WriteLine($" {param.Parameter.Name}: {param.Result}");
}

This is useful when you have a CallInput but have not executed it yet — for example, decoding a pending transaction's input data.

Call Types​

The CallType enum distinguishes how contracts interact:

CallTypeDescriptionStorage Context
CallStandard external callTarget contract's storage
DelegateCallExecute target code in caller's contextCaller's storage
StaticCallRead-only call (no state changes)Target contract's storage
CallCodeLegacy version of DelegateCallCaller's storage
CreateDeploy new contractNew contract's storage
Create2Deterministic contract deploymentNew contract's storage

Understanding DelegateCall is important for proxy contracts — the call tree shows the target (implementation) address, but storage changes affect the proxy.

Next Steps​